Think of GitHub Actions as your digital assistant that never sleeps. Every time something happens in your repository - like pushing code or creating a pull request - Actions can automatically run tasks for you.
Here’s what makes it powerful:
- Built into GitHub - No external tools to set up
- Event-driven - Responds to repository activities automatically
- Free tier - 2,000 minutes per month for public repos
- Flexible - Run tests, deploy code, send notifications, and more
The Building Blocks
Before we dive into code, let’s understand the key components:
Workflows
These are the automation recipes stored in .github/workflows/
directory. Think of them as instruction manuals that tell GitHub what to do and when.
Events
The triggers that kick off your workflows. Common ones include:
push
- Someone pushes codepull_request
- A PR is opened or updatedschedule
- Run on a timer (like cron jobs)workflow_dispatch
- Manual trigger
Jobs
The actual work your workflow does. Each job runs on a fresh virtual machine.
Steps
Individual tasks within a job, like running tests or deploying code.
Actions
Reusable pieces of code that perform specific tasks. You can use ones from the marketplace or create your own.
Your First Workflow: Basic CI
Let’s start with something practical - a workflow that runs tests every time you push code. Create this file in your repository:
.github/workflows/ci.yml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
name: CI Pipeline
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
- name: Run linter
run: npm run lint
This workflow does four things:
- Checks out your code
- Sets up Node.js with dependency caching
- Installs dependencies
- Runs tests and linting
Every push to main
or develop
will trigger this workflow. Every pull request targeting main
will also run it. No more “works on my machine” surprises.
Matrix Builds: Test Everywhere
Want to test your code across multiple Node.js versions? Use a matrix build:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
name: Cross-Platform CI
on: [push, pull_request]
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
node-version: [16, 18, 20]
steps:
- uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- run: npm ci
- run: npm test
This creates 9 jobs (3 operating systems × 3 Node.js versions) and runs them in parallel. You’ll know your code works everywhere, not just on your laptop.
Secrets and Environment Variables
Never hardcode API keys or passwords. Use GitHub’s encrypted secrets instead:
1
2
3
4
5
6
7
8
steps:
- name: Deploy to production
env:
API_KEY: ${{ secrets.API_KEY }}
DATABASE_URL: ${{ secrets.DATABASE_URL }}
run: |
echo "Deploying with API key..."
# Your deployment script here
Add secrets in your repository settings under Settings > Secrets and variables > Actions.
Conditional Logic
Sometimes you only want steps to run under specific conditions:
1
2
3
4
5
6
7
8
9
10
11
steps:
- name: Run integration tests
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: npm run test:integration
- name: Notify Slack on failure
if: failure()
run: |
curl -X POST -H 'Content-type: application/json' \
--data '{"text":"Build failed on ${{ github.ref }}"}' \
${{ secrets.SLACK_WEBHOOK_URL }}
The if
condition supports various contexts:
success()
- Previous steps succeededfailure()
- Any previous step failedalways()
- Run regardless of previous step status
Artifacts: Save Your Work
Need to preserve build outputs or test results? Use artifacts:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
steps:
- name: Build application
run: npm run build
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: build-files
path: dist/
- name: Upload test results
uses: actions/upload-artifact@v4
if: always()
with:
name: test-results
path: test-results.xml
Artifacts are stored for 90 days by default and can be downloaded from the workflow run page.
Deployment Workflow
Here’s a practical deployment workflow that only runs when tests pass:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
name: Deploy to Production
on:
push:
branches: [ main ]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '18'
- run: npm ci
- run: npm test
deploy:
needs: test
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Deploy to server
env:
DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
run: |
# Your deployment script
echo "Deploying to production..."
The needs: test
ensures deployment only happens after tests pass. The if
condition adds an extra safety check.
Common Pitfalls to Avoid
1. Hardcoding Values
1
2
3
4
5
# Bad
run: echo "Deploying to https://myapp.com"
# Good
run: echo "Deploying to ${{ vars.APP_URL }}"
2. Not Using Caching
1
2
3
4
5
6
7
8
# Slow
- run: npm install
# Fast
- uses: actions/setup-node@v4
with:
cache: 'npm'
- run: npm ci
3. Ignoring Security
1
2
3
4
5
6
7
# Dangerous
run: curl -H "Authorization: Bearer ${{ secrets.TOKEN }}" | bash
# Safer
run: |
response=$(curl -H "Authorization: Bearer ${{ secrets.TOKEN }}" https://api.example.com)
echo "$response" | jq '.status'
Architecture Overview
Here’s how GitHub Actions fits into your development workflow:
Developer pushes code
↓
GitHub receives push event
↓
Workflow triggers automatically
↓
Runner starts (Ubuntu/Windows/macOS)
↓
Steps execute in sequence
↓
Results reported back to GitHub
↓
Notifications sent (email/Slack/etc)
The beauty is that this entire process happens without any manual intervention. You push code, GitHub handles the rest.
Best Practices for Production
1. Use Specific Action Versions
1
2
3
4
5
# Good
uses: actions/checkout@v4.1.1
# Avoid
uses: actions/checkout@main
2. Fail Fast
1
2
3
4
strategy:
fail-fast: true
matrix:
node-version: [16, 18, 20]
3. Set Timeouts
1
2
3
4
jobs:
test:
timeout-minutes: 10
runs-on: ubuntu-latest
4. Use Environments for Deployments
1
2
3
deploy:
environment: production
runs-on: ubuntu-latest
Monitoring and Debugging
When workflows fail, GitHub provides detailed logs for each step. Common debugging techniques:
- Add debug output
1 2 3 4 5
- name: Debug info run: | echo "GitHub ref: ${{ github.ref }}" echo "Event name: ${{ github.event_name }}" ls -la
- Use the GitHub CLI
1 2
- name: Check PR status run: gh pr view --json state,title
- Enable debug logging
SetACTIONS_STEP_DEBUG
totrue
in repository secrets for verbose output.
What’s Next?
You now have the foundation to automate your development workflow. Start small:
- Create a basic CI workflow for your current project
- Add automated testing
- Set up deployment for a staging environment
- Gradually add more sophisticated automation
GitHub Actions can do much more than we covered - from managing releases to automating issue responses. But these basics will handle 90% of what most developers need.
Got questions about GitHub Actions or want to share your automation wins? Drop a comment below. And if you found this helpful, consider sharing it with a fellow developer.