84.4 GitHub Actions: Running Tests and Linting on Push
Right, let’s get your code off your machine and into the cold, unforgiving light of automation. You’re pushing to GitHub, which is great, but hope is not a strategy. We need proof. We’re going to set up a GitHub Action that acts as your brilliant, hyper-vigilant code guardian, running your tests and linter on every single git push. This is the bedrock of CI/CD: trusting, but verifying, constantly.
Think of it as a tiny robot that lives in the .github/workflows directory of your repo. You give it a recipe (a YAML file), and it spins up a fresh, clean virtual machine (a ‘runner’), follows your instructions to the letter, and reports back. No “but it worked on my machine” here. This is the machine that matters.
Your First Workflow File: ci.yml
Let’s start with a simple but powerful workflow. Create a file at .github/workflows/ci.yml. The name isn’t magical, but ci.yml is a solid convention.
name: CI
on: [push]
jobs:
test-and-lint:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm ci
- name: Run linter
run: npm run lint
- name: Run tests
run: npm test
Let’s break this down because YAML is a language designed to look simple while hiding subtle ways to ruin your day. The on: [push] directive is the trigger. This workflow will run on every push to every branch. We’ll refine that later, but for now, it’s gloriously blunt.
The jobs block contains, well, jobs. Each job runs on a fresh runner. The runs-on specifies the OS; ubuntu-latest is the workhorse of GitHub Actions. Inside a job, you define steps. These run in order, and if any step fails, the whole job fails, which is exactly what we want.
Notice the two types of steps: uses and run. uses is for pulling in pre-built actions from the GitHub community (like actions/checkout@v4 to get your code and actions/setup-node@v4 to handle Node.js setup). run is for executing shell commands. Crucial best practice: Use npm ci instead of npm install. It’s faster for CI environments because it expects a lockfile and does a clean, reproducible install, throwing an error if package-lock.json and package.json are out of sync. It’s the pedantic, rules-loving sibling of npm install, and you want that sibling on your CI team.
Getting Smarter with Matrix Builds and Caching
Running on one version of Node.js is fine. Running on the versions your app actually supports is professional. Let’s use a build matrix. This is where GitHub Actions shines, running parallel jobs across different configurations.
# ... name and on trigger remain the same ...
jobs:
test-and-lint:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18, 20, 22]
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- name: Cache node 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
- name: Run linter
run: npm run lint
- name: Run tests
run: npm test
The strategy.matrix block creates a job for each value in the array. Now, one push will trigger three jobs: one for Node.js 18, one for 20, and one for 22. You get immediate feedback on cross-version compatibility. The ${{ matrix.node-version }} syntax is for GitHub’s expression context, letting you inject the current value of the matrix into your steps.
The other huge win here is caching. Downloading all of node_modules on every single run for every single Node version is painfully slow and a great way to burn through your allotted CI minutes. The actions/cache@v4 step creates a cache of the ~/.npm directory (where npm stores the downloaded packages) keyed by the OS and a hash of your package-lock.json. If the lockfile hasn’t changed, it restores the cache, making npm ci dramatically faster.
Don’t Be a Dogmatist: Skipping Steps for Certain Changes
Here’s a common pitfall: running your full test suite on a push that only changes the README.md. It’s a waste of resources and time. We can be smarter. Let’s use paths to conditionally run jobs.
jobs:
test-and-lint:
runs-on: ubuntu-latest
# Only run this job if files outside of docs/ were changed
if: ${{ !contains(github.event.head_commit.message, '[skip ci]') && !contains(github.event.commits.*.modified, 'docs/') }}
steps:
# ... steps remain the same ...
This if condition is your first line of defense against unnecessary CI. It checks two things: 1) that the commit message doesn’t contain [skip ci] (a common convention to bypass CI), and 2) that none of the modified files are in the docs/ directory. You can tailor this to your project—maybe skip CI for changes only to .md files or specific config directories. The key is to think intentionally about what changes actually require validation.
The Reality of Secrets and Environment Variables
You will eventually need an API key, a database password, or a deployment token. Never, ever hardcode these into your YAML file. GitHub provides a brilliantly simple solution: secrets. You set them in your repository’s Settings > Secrets and variables > Actions. Then, you can inject them into your workflow as environment variables.
- name: Run tests that need an API key
run: npm run test:integration
env:
SUPER_SECRET_API_KEY: ${{ secrets.SUPER_SECRET_API_KEY }}
This makes the secret available only to that specific step as an environment variable. The value of secrets.SUPER_SECRET_API_KEY is pulled from the repository’s secrets store and is masked in all logs. If you try to print it, GitHub will replace it with ***. It’s a robust system that does the one thing it’s supposed to do: keep your secrets secret.
This is your foundation. It’s the automated safety net that lets you push code with confidence, knowing that if anything breaks, you’ll be the first to know, along with everyone else on your team who gets the alert. And that’s a much better way to find out than from a furious user.