A Small GitHub Actions Workflow That Runs Your Tests
A compact GitHub Actions workflow that tests a Node project on pushes and pull requests, with npm caching, a concurrency group, read-only permissions, SHA-pinned actions, and a short routine for reading failed runs.

The first workflow most repositories need is the boring one: run the tests on every pull request and on every push to main. Here’s one that does exactly that, plus a handful of lines that stop it from wasting runner minutes or handing out more access than a test job needs.
It assumes a Node project with a package-lock.json in the repository root and a test script in package.json. Swap the setup step for your language; the rest carries over.
The workflow
Save this as .github/workflows/test.yml:
name: Test
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
test:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 24
cache: npm
- run: npm ci
- run: npm test
Most of it explains itself. These are the lines worth a sentence each.
Triggers: why not every push
push is limited to main, and pull_request covers every other branch once it has a pull request. If push ran on all branches, every commit to a PR branch would start two identical runs: one for the push, one for the pull request. Same result, double the minutes. The trade-off is that a branch without a PR gets no runs; open a draft PR if you want feedback early.
Permissions: read-only by default
Every run gets an automatic, short-lived credential called GITHUB_TOKEN. The permissions block decides what it can do. As soon as you list one permission, every permission you didn’t list drops to none. So contents: read, which is enough to check out the code, is the whole grant.
New personal repositories already default to read-only for contents and packages. Older repositories, or ones whose organization uses the permissive setting, may not. Writing it in the file means the workflow behaves the same everywhere, and a reviewer can see the grant without visiting Settings. If a job later needs to comment on PRs, give that job pull-requests: write at the job level and leave the rest alone.
persist-credentials: false follows the same thinking. By default, actions/checkout keeps the token available so later steps can run git push. A test job never pushes, so there’s no reason to leave it lying around.
Concurrency: one run per branch at a time
The group puts every run of this workflow for the same ref into one queue. When you push a fix to a PR while the previous run is still going, the old run is cancelled; nobody needs results for a commit that’s already been replaced. On main, cancel-in-progress evaluates to false, so a running job is allowed to finish.
Only one run waits behind the active one, though. If several pushes land while a run is in progress, only the newest waits; the ones in between are cancelled. For tests, that’s usually what you want.
If a PR run shows up as cancelled, check whether you pushed again shortly afterwards. That’s this block doing its job, not a flaky runner.
Runner and timeout
ubuntu-latest currently means Ubuntu 24.04. Ubuntu 26.04 images are already available, and GitHub moves -latest labels gradually when it switches. Naming ubuntu-24.04 means that switch happens when you decide, not on some random Tuesday.
timeout-minutes defaults to 360. That’s six hours for a hung test to sit there using runner time. Fifteen is generous for a small project.
Pinned actions
actions/checkout@v7 points to a tag, and tags can move. The maintainer moves v7 on every release. Anyone who compromises the action’s repository could move it too. A full commit SHA can’t be changed, which is why GitHub’s security guidance recommends pinning third-party actions to one. The # v7.0.1 comment is there for humans, and for Dependabot.
To look up the SHA for a release yourself:
git ls-remote --tags https://github.com/actions/checkout v7.0.1
3d3c42e5aac5ba805825da76410c181273ba90b1 refs/tags/v7.0.1
Some projects use annotated tags, which print a second line ending in ^{}. The SHA on that line is the commit; use it.
Pins don’t update themselves, so let Dependabot open the pull requests. Add .github/dependabot.yml:
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
Dependabot updates both the SHA and the version comment on the same line. One catch: Dependabot doesn’t raise security alerts for SHA-pinned actions, so those weekly update PRs are how fixes reach you. Merge them. (GitHub also has a workflow dependency lockfile in public preview, .github/workflows/actions.lock, which covers transitive dependencies too. For one small workflow, pins plus Dependabot are enough.)
Caching: what it actually saves
cache: npm makes setup-node cache npm’s download cache, not node_modules. The cache key is a hash of package-lock.json, so the cache stays valid until the lockfile changes. npm ci still runs every time; it just doesn’t have to download every package again.
You can follow it in the logs. The first run of the setup step says:
npm cache is not found
When the job finishes, a post step reports Cache saved with the key: node-cache-Linux-x64-npm-…. Next time, the setup step says Cache restored from key: with the same key.
If the setup step fails with Dependencies lock file is not found in …, there’s no lockfile in the repository root. Commit package-lock.json, or point cache-dependency-path at it if it lives in a subdirectory.
setup-node’s README suggests turning caching off (package-manager-cache: false) in workflows that have elevated privileges, to limit cache poisoning. A read-only test job isn’t one of those.
Reading a failed run
Open the run from the Actions tab or from the PR’s checks, then click the failed job. The step with the red mark is already expanded, and its last line is usually:
Error: Process completed with exit code 1.
That line is the runner reporting that your command exited with a non-zero code, so it tells you almost nothing about the actual problem. Scroll up to the first error in the step, not the last. Test runners like to print a summary at the bottom that only repeats the damage.
From a terminal, the GitHub CLI skips the clicking:
gh run list --workflow test.yml --limit 5
gh run view <run-id> --log-failed
--log-failed prints only the logs of the steps that failed.
A few patterns come up repeatedly:
npm cifails whilenpm installworks on your machine:package-lock.jsondoesn’t matchpackage.json.npm cirefuses to fix that for you. Runnpm installlocally and commit the updated lockfile.- It passes locally and fails in CI: compare Node versions first. If the repository has an
.nvmrc, usenode-version-file: .nvmrcinstead of a hard-coded number so the two can’t drift apart. - Every commit gets a failed run with no logs at all: the workflow file itself is invalid. GitHub shows the error on the run page, and keeps creating a failed run for each new commit until the file is fixed.
- The run says cancelled: see the concurrency section. A newer push replaced it.
- A PR gets no run at all: check for a merge conflict.
pull_requestworkflows don’t run while the PR has one.
To try again:
gh run rerun <run-id> --failed
gh run rerun <run-id> --failed --debug
--debug turns on debug logging (step debug and runner diagnostic logs) for the re-run. The web UI has the same option as an Enable debug logging checkbox. One thing to keep in mind: a re-run uses the same commit SHA and ref as the original run. If you’ve fixed the code, push the fix. Re-running the old run just tests the old code again, very thoroughly.
When main goes red and nobody knows which commit did it, Git Bisect for a Small Regression is the quickest way to find out.
References
- GitHub Docs: Workflow syntax for GitHub Actions
- GitHub Docs: Events that trigger workflows
- GitHub Docs: Control the concurrency of workflows and jobs
- GitHub Docs: Secure use reference
- GitHub Docs: Workflow dependency lockfiles
- GitHub Docs: Managing GitHub Actions settings for a repository
- GitHub Docs: Keeping your actions up to date with Dependabot
- GitHub Docs: Re-running workflows and jobs
- GitHub Docs: Enabling debug logging
- GitHub Docs: Using workflow run logs
- actions/checkout and releases
- actions/setup-node and releases
- actions/runner-images: available images
- GitHub CLI manual: gh run
Cover photo: Unsplash.