GitHub Actions CI Pipeline in Practice: Build Automated Testing from Scratch
It’s 3 AM. Your phone vibrates. A colleague messages: “Production is down. The code you merged yesterday has issues.”
Your mind goes blank. You tested it locally, right? After checking the logs, you discover the problem: your local Node version is 20, but the test environment uses 18. An API behaves differently between versions. In that moment, you want to scream—if only there was a CI pipeline running tests automatically before merging, this never would have happened.
Let’s be honest: nine out of ten developers forget to run tests manually. The one who doesn’t forget has probably been burned before. GitHub Actions solves this problem: push code, and it automatically builds, tests, and deploys. No mental overhead—machines handle it for you.
In this article, I’ll guide you through building a complete CI pipeline from scratch. Including: a ready-to-use workflow template you can copy directly, Matrix strategy for multi-version parallel testing (cutting build time by more than half), and lessons learned from real-world experience. Ready? Let’s begin.
Chapter 1: GitHub Actions Quick Start
What is GitHub Actions
Simply put, GitHub Actions is GitHub’s built-in automation platform. You push code locally, and it runs tests, builds, and deployments in the cloud—fully automated.
In the past, setting up CI/CD meant building your own Jenkins server, with all the configuration, maintenance, and upgrades that entails. GitHub Actions’ strength lies in: no server management, no software installation—just drop a YAML file in your repository. Plus, you get 2000 free minutes per month (unlimited for public repos), which is plenty for personal projects and small teams.
Compared to Jenkins and Travis CI, GitHub Actions has clear advantages: deep integration with GitHub repos, build status visible directly in PRs; simple configuration without learning Groovy syntax; rich ecosystem with thousands of ready-to-use Actions in the official Marketplace. Downsides exist too: you’re locked into GitHub, migrating to GitLab means rewriting configs; for complex enterprise pipelines, Jenkins might still be more flexible. But for most projects, GitHub Actions is sufficient.
Core Concepts Overview
When starting with GitHub Actions, it’s easy to get confused by several concepts. Let me explain them in plain terms:
Workflow: A YAML file defining an entire automation process. For example, “run tests every time code is pushed to the main branch”—that’s a workflow. Placed in the .github/workflows/ directory.
Job: A group of steps within a workflow. Multiple Jobs can run in parallel or have dependencies. For example, run a “test” Job first, then a “deploy” Job.
Step: Specific operations within a Job, executed sequentially. Can be running a command (npm test) or calling a pre-built Action (actions/checkout@v4).
Runner: The virtual machine executing the Job. GitHub provides three types: ubuntu-latest (Linux), windows-latest (Windows), macos-latest (macOS). You can use your own servers, but the official ones suffice for most cases.
Think of it this way: Workflow is a script, Job is a scene in the script, Step is the specific actions in each scene, and Runner is the actor performing the scene.
Your First CI Workflow
Don’t overthink it—let’s get it running first. Create .github/workflows/ci.yml in your project root and paste this code:
name: CI Pipeline # Workflow name, displayed in GitHub Actions page
on:
push:
branches: [main] # Triggers when pushing to main branch
pull_request:
branches: [main] # Triggers when PR targets main branch
permissions:
contents: read # Principle of least privilege: read-only access
jobs:
build:
runs-on: ubuntu-latest # Use latest Ubuntu environment
timeout-minutes: 15 # Timeout to prevent hanging
steps:
- name: Checkout code
uses: actions/checkout@v4 # Pull code
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20 # Use Node.js 20
cache: 'npm' # Enable npm cache
- name: Install dependencies
run: npm ci # Install dependencies, ci is faster and more reliable than install
- name: Run tests
run: npm test # Run tests
- name: Build
run: npm run build # Build
What does this code do?
The first part on defines trigger conditions: triggers when pushing to main branch or when PR targets main. The second part permissions declares permissions, following the principle of least privilege with only contents: read, preventing the workflow from accidentally modifying the repository. The third part is the core: a Job named build, running on Ubuntu, sequentially executing pull code, install Node, install dependencies, run tests, and build.
Commit this code, push to GitHub, then open your repository’s Actions page. You’ll see a green circle spinning—that’s the Runner executing your workflow. Wait a few minutes, and if everything turns into green checkmarks, congratulations—your first CI pipeline is running.
What if you see red X’s? Click in to view the logs, each step’s output is clearly shown. 90% of the time it’s dependency installation failure or test issues, not CI configuration problems.
Chapter 2: CI Pipeline Core Configuration
The workflow from Chapter 1 works, but it’s not quite production-ready yet. This chapter covers four core configurations: triggers, permission management, environment variables, and dependency caching. These are the backbone of CI pipelines—proper configuration makes your workflow more secure and efficient.
Triggers: When to Run
Triggers determine when a workflow starts. The two most common: push and pull_request.
on:
push:
branches: [main, dev] # Triggers when pushing to main or dev branch
paths:
- 'src/**' # Only triggers when files under src/ change
- 'package.json' # Also triggers when package.json changes (dependency updates)
pull_request:
branches: [main] # Triggers when PR targets main
The paths filter is particularly useful. For example, if your project has a docs/ directory, documentation changes shouldn’t trigger CI. Adding paths configuration ensures builds only run when code changes, saving resources and time.
Besides push and PR, there are other trigger types:
schedule: Scheduled tasks using cron expressions. For example, run a build every midnight:
on:
schedule:
- cron: '0 0 * * *' # Every day at UTC 0:00 (Beijing time 8:00)
I have a project using this for periodic dependency checks: runs npm outdated daily, detects outdated packages, and automatically sends email reminders.
workflow_dispatch: Manual trigger. Sometimes you want to run a build without pushing code (like testing a configuration), and this comes in handy. The Actions page will show a “Run workflow” button—click it to run manually.
on:
workflow_dispatch: # Manual trigger, no extra configuration needed
Permission Management: Security First
GitHub Actions defaults to giving workflows a GITHUB_TOKEN, which can read/write repositories, create PRs, and even push code. Sounds convenient, but also means risk: if a workflow is maliciously exploited, attackers can get write access to your repository.
There was a security incident in 2021 where an open-source project’s CI workflow was exploited, with attackers submitting malicious code through a fake PR. The lesson was painful. So GitHub now recommends a principle: explicitly declare minimum permissions.
permissions:
contents: read # Only read repository contents, no write
pull-requests: write # If creating PRs is needed, declare separately
For pure CI pipelines (only running tests and builds), contents: read is sufficient. If the workflow needs to publish Releases or comment on PRs, add permissions as needed.
A practical tip: in repository settings, change default permissions to “Read repository contents permission”. This way all workflows default to read-only, with write permissions declared individually when needed. One more layer of defense, one less risk.
Environment Variables: Layered Management
Environment variables have three levels: workflow-level, job-level, and step-level. Lower levels have smaller scopes but can override higher-level values.
env:
NODE_ENV: production # Workflow-level, all jobs can use
CI: true # Many tools adjust behavior when detecting this variable
jobs:
build:
env:
BUILD_TARGET: web # Job-level, only valid in build job
steps:
- name: Run custom script
env:
MY_VAR: hello # Step-level, only valid in this step
run: echo $MY_VAR
Why layer? For example: your project has multiple Jobs, build and deploy. Both need NODE_ENV, so put it at workflow-level. But BUILD_TARGET is only used by the build Job, clearer at job-level. If a step needs a temporary variable (like a script parameter), put it at step-level.
How to handle sensitive information? Never write it directly in YAML. GitHub provides Secrets functionality: add secrets in repository settings (like API_KEY), reference in workflow with ${{ secrets.API_KEY }}. Secrets are automatically masked in logs and won’t leak.
steps:
- name: Deploy to server
env:
SSH_KEY: ${{ secrets.SSH_KEY }} # Reference from Secrets
run: |
echo "$SSH_KEY" > private.key
ssh -i private.key user@server 'deploy.sh'
Dependency Caching: Speed Up Builds
If your project has many dependencies (hundreds of npm packages), installing from scratch each CI run takes a long time. One of my projects takes 3 minutes to install dependencies and 1 minute to run tests—dependency installation takes 75% of the time.
GitHub Actions provides caching mechanisms to store installed dependencies for reuse. The simplest way: setup-node has built-in caching.
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm' # Automatically cache npm dependencies
Add one line cache: 'npm', the first run installs normally while caching node_modules. The second run, if package-lock.json hasn’t changed, pulls directly from cache—installation time drops from 3 minutes to 10 seconds.
If you use pnpm or yarn, change to cache: 'pnpm' or cache: 'yarn'.
For more granular caching, use actions/cache:
- name: Cache dependencies
uses: actions/cache@v4
with:
path: ~/.npm # npm global cache directory
key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
restore-keys: |
npm-${{ runner.os }}-
The key is the cache’s unique identifier. Here we use the hash of package-lock.json: if the lock file changes, cache invalidates and reinstalls. restore-keys is a fallback strategy: if exact match isn’t found, find similar caches to partially restore.
With high cache hit rates, build speeds improve significantly. I tested a project: 4 minutes without cache, 1.5 minutes with cache. Running 20 builds daily, the time saved is enough to write a blog post.
Chapter 3: Matrix Strategy: Parallel Testing
This is my favorite GitHub Actions feature and the core selling point of this article. Matrix strategy turns one Job into multiple parallel Jobs, testing different versions and operating systems simultaneously. One push launches dozens of build tasks in seconds, all running in parallel, reducing total time by more than half compared to sequential execution.
What is Matrix
Here’s an analogy: you need to test whether your project works correctly under Node 16, 18, and 20. The traditional approach is writing three Jobs or switching versions sequentially in one Job. This results in either redundant configuration or long execution times.
Matrix is like a table: horizontal axis is Node version, vertical axis is operating system, each cell is an independent test task. GitHub Actions automatically generates all combinations and executes them in parallel.
strategy:
matrix:
node: [16, 18, 20]
os: [ubuntu-latest, windows-latest]
This configuration generates 6 Jobs: Node 16 on Ubuntu, Node 16 on Windows, Node 18 on Ubuntu… and so on. Total completion time depends on the slowest Job, not the sum of all Jobs.
Version Matrix: Multi-Version Node Testing
I encountered this problem in a project: development used Node 20, then one day a user reported it wouldn’t run on Node 18. Investigation revealed an API behaves differently under Node 18. If multi-version testing had been done earlier, this bug never would have shipped.
Using Matrix for multi-version testing is simple:
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false # If one version fails, others continue
matrix:
node-version: [16, 18, 20, 22] # Test these four versions
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }} # Dynamically use version from matrix
cache: 'npm'
- run: npm ci
- run: npm test
Key points:
matrix.node-versiondefines the version list to test${{ matrix.node-version }}references in steps, each Job gets a different valuefail-fast: falsemeans if one version fails, others continue running. Default istrue, stopping all on first failure. For compatibility testing, recommend turning this off so you can see all version test results.
include and exclude: Sometimes certain combinations aren’t needed, or need extra configuration—use these two keywords.
strategy:
matrix:
node-version: [16, 18, 20]
os: [ubuntu-latest, windows-latest]
exclude:
- node-version: 16 # Don't test Node 16 + Windows combination
os: windows-latest
include:
- node-version: 20 # Node 20 runs an extra test on macOS
os: macos-latest
exclude removes unneeded combinations, include adds extra combinations. Flexible control over testing scope.
OS Matrix: Cross-Platform Testing
If your project might run on different operating systems (like command-line tools), OS matrix is very useful.
jobs:
test:
runs-on: ${{ matrix.os }} # Dynamically specify operating system
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
node-version: [18, 20]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- run: npm ci
- run: npm test
Several points to note:
Platform differences: Windows and Linux have different file paths (\ vs /), and some command-line tools behave differently. Cross-platform testing helps discover these issues early.
Cost control: GitHub Actions charges differently for different operating systems. Linux is free for 2000 minutes/month, Windows consumes 2x Linux, macOS consumes 10x. If you use macOS Runner for testing, monthly quota depletes quickly.
Money-saving strategies:
- Only test macOS when necessary (if the project actually runs on macOS)
- Put macOS tests in a separate workflow, manually trigger with
workflow_dispatch - Public repos have unlimited minutes—if your project can be public, make it public
Performance Improvement Tips
Matrix enables parallelism, but it’s not unlimited. GitHub defaults to limiting parallel jobs to prevent resource exhaustion. You can control this manually:
strategy:
max-parallel: 4 # Maximum 4 Jobs running simultaneously
matrix:
node-version: [16, 18, 20, 22]
If your Matrix has many combinations (like 10+ Jobs), setting max-parallel prevents running too many at once, reducing free quota consumption.
Cache hit acceleration: Each Job in Matrix has its own cache. setup-node’s cache handles this automatically, no extra configuration needed. The key is high cache hit rate when package-lock.json and node-version are stable.
Reduce unnecessary steps: Some steps run in every Matrix Job but aren’t needed. For example, code linting usually doesn’t need multi-version testing:
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 # lint only runs once on Node 20
test:
needs: lint # run test after lint succeeds
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 }}
cache: 'npm'
- run: npm ci
- run: npm test
This way lint runs once, test runs on three versions. More efficient.
Real-world data: One of my projects, testing 3 Node versions sequentially took 12 minutes. Using Matrix to run in parallel, total time was 4 minutes (depending on the slowest version). Saved 8 minutes—running 10 builds daily, the monthly time saved is enough to watch a movie.
Chapter 4: Practical Experience and Troubleshooting
The previous three chapters covered how to configure CI pipelines. This chapter is a “quick reference checklist”—three dimensions: security, performance, and troubleshooting. When you encounter problems, flip here to save time.
Security Practice Checklist
| Practice | Description | Example |
|---|---|---|
| Explicitly declare permissions | Don’t rely on default permissions, clearly declare what’s needed | permissions: { contents: read } |
| Use Secrets for sensitive information | Don’t hardcode API Keys, SSH keys, etc. | ${{ secrets.API_KEY }} |
| Limit trigger branches | Don’t run CI on all branches, only needed ones | branches: [main] |
| Reference Actions by SHA | Use specific commit SHA instead of version tags to prevent tampering | actions/checkout@b4ffde65f46336ab88eb53be808477a39b6bc2b1 |
| Set timeout | Prevent hanging from wasting quota | timeout-minutes: 15 |
The last one many people overlook: Action version references. Official Actions usually use tags like @v4 for easy upgrades. But tags can be modified—theoretically someone could point @v4 to malicious code. Using SHA reference (@b4ffde65f...) is less convenient for upgrades but more secure. For production projects, recommend using SHA.
Performance Improvement Checklist
| Tip | Effect | Configuration |
|---|---|---|
| Enable dependency caching | Save 50%+ installation time | cache: 'npm' |
| Use npm ci instead of install | Faster, more reliable installation | run: npm ci |
| Set timeout-minutes | Prevent hanging from wasting quota | timeout-minutes: 15 |
| Matrix parallel execution | Reduce total time by 60%+ | strategy.matrix |
| Use concurrency to cancel duplicate builds | Only run latest on same branch | concurrency.group: ${{ github.ref }} |
concurrency is quite practical: you push 5 times to the same branch consecutively, by default 5 builds would run. With concurrency configuration, the first 4 are cancelled, only the last one runs.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true # Cancel running old builds
Common Problems Quick Reference
| Error Message | Cause | Solution |
|---|---|---|
Permission denied | Insufficient permissions | Check permissions configuration, add needed permissions |
Cache not found | Cache key mismatch | Check cache key, ensure package-lock.json hasn’t changed |
npm ERR! network | Network timeout | Increase timeout or use mirror |
Out of memory | Node memory insufficient | Set NODE_OPTIONS=--max_old_space_size=4096 |
EACCES permission denied | File permission issue | Add chmod +x script.sh at script start |
Error: Cannot find module | Dependencies not installed | Ensure npm ci executed successfully, check error logs |
Handling for common scenarios:
Network timeout: Sometimes GitHub Runner connects slowly to npm registry. You can configure a mirror in .npmrc:
- name: Configure npm registry
run: echo "registry=https://registry.npmmirror.com" > .npmrc
Out of memory: Large projects may hit Node memory limits during build. Add an environment variable:
env:
NODE_OPTIONS: --max_old_space_size=4096 # Allocate 4GB memory to Node
Cache not working: No cache on first run is normal. Ensure package-lock.json exists (npm ci needs the lock file), and setup-node’s cache parameter matches your package manager (npm/pnpm/yarn).
Conclusion
After all this discussion, it really comes down to a few core points: a single YAML file sets up a CI pipeline; permission declarations follow the principle of least privilege; environment variables are managed in three layers; dependency caching cuts installation time in half; Matrix strategy makes multi-version parallel testing simple.
Copy the workflow template from Chapter 1, tweak the Node version and project commands, and you’ve got CI for your project. Get it running first, then refine gradually. I encourage you to try Matrix strategy—even testing just two Node versions, you’ll experience the efficiency gain from parallel testing. That feeling when multiple green checkmarks appear simultaneously is quite satisfying.
If you encounter problems using GitHub Actions, leave a comment. I’ll add common issues to the quick reference table in Chapter 4, helping more people avoid pitfalls.
Build GitHub Actions CI Pipeline
Build a complete CI pipeline from scratch to implement automated build and test
⏱️ Estimated time: 30 min
- 1
Step1: Create workflow directory
Create `.github/workflows/` directory in project root to store all workflow configuration files. - 2
Step2: Write basic CI configuration
Create `ci.yml` file, configure triggers (push/PR), permissions (principle of least privilege), Job steps (checkout, setup-node, install, test, build). - 3
Step3: Enable dependency caching
Add `cache: 'npm'` parameter in `setup-node` step to automatically cache npm dependencies and speed up subsequent builds. - 4
Step4: Configure Matrix multi-version testing
Add `strategy.matrix` configuration, specify Node version list to test (e.g., [16, 18, 20]) to enable parallel testing. - 5
Step5: Commit and observe build results
Commit configuration file, push to GitHub, open Actions page to view build status and logs.
FAQ
What is GitHub Actions' monthly free quota?
Which branches should CI workflow trigger on?
Why recommend npm ci over npm install?
How much build time can Matrix strategy reduce?
Why does caching sometimes not work?
How to use sensitive information (API Key, SSH key) in CI?
14 min read · Published on: Apr 6, 2026 · Modified on: Apr 20, 2026
Related Posts
Nginx SSL/TLS Configuration in Practice: From HTTPS Certificates to A+ Security Hardening
Nginx SSL/TLS Configuration in Practice: From HTTPS Certificates to A+ Security Hardening
Docker Multi-Stage Build in Practice: Shrinking Production Images from 1GB to 10MB
Docker Multi-Stage Build in Practice: Shrinking Production Images from 1GB to 10MB
Supabase Edge Functions in Practice: Deno Runtime and TypeScript Development Guide

Comments
Sign in with GitHub to leave a comment