Cache Early, Debug Loudly, Vet Every Action

Why caching npm installs is worth doing before it's slow, the flag that shows every command a step actually runs, and what to check before trusting someone else's action.

September 9, 20263 min read2 / 4

Right now, npm ci finishes in a few seconds. That's exactly the wrong time to decide caching doesn't matter yet.

This is the same instinct as pinning your Node version and dependencies before drift becomes a problem: fix the thing while it's a one-line change, not once it's already slow.

Cache before it's a problem, not after

Nobody adds new dependencies to a two-file proof-of-concept. A stable project with multiple contributors is a different story: packages get added continuously, and every one of them adds install time.

Caching is one line to add now, and a much bigger cleanup to add later once a slow install is already costing the team real time on every single run.

YAML
- uses: actions/setup-node@v4 with: node-version: 22.22.0 cache: npm

That's the whole change for a Node project. setup-node already knows where npm keeps its cache and how to key it off your lockfile, so cache: npm is enough.

If your language doesn't have that built in, the manual version is actions/cache:

YAML
- uses: actions/cache@v4 with: path: ~/.npm key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}

The key is built from a hash of your lockfile. Change a dependency, the hash changes, and that's a cache miss, a fresh install that gets cached again for next time. Leave the lockfile alone, and every run after the first one restores instantly instead of reinstalling from scratch.

See every command a step actually runs

The other stage-two habit worth building: turning on step debugging when something fails and the reason isn't obvious.

YAML
env: ACTIONS_STEP_DEBUG: true

Set this as a repository secret (not a plain variable), and every step starts printing the exact commands it runs as it runs them, the same idea as set -x in a shell script. Normally you only see a script's output. With this on, you see the commands generating that output too.

One real caveat: if a script embeds a secret in a command, that command becomes visible in the log. Turn this on to diagnose a specific failure, not as a permanent setting.

An action is a dependency you didn't write

Every uses: line in a workflow pulls in code you didn't write and probably haven't read. Treat it exactly like you'd treat an npm package: something to check before you trust it, not after.

📅Updated in the last year?
⭐Real star count, real usage?
🛡️Security advisories tab clean?

An action nobody has touched in three years, with a handful of stars and no security policy, is a real supply chain risk, not a hypothetical one. It runs with whatever access your workflow already grants it, the exact same concern behind never handing a job broader access than the task actually needs. Actions published by GitHub itself or by the platform vendor (AWS, Docker) are the safest default. A community action with a large, active user base and a visible security policy is the next tier. An action from an account you've never heard of, created two days ago, is the one to skip, especially anywhere near a secret.

The Essentials

  1. Cache dependencies before the install is slow, not after. For a Node project, cache: npm on setup-node is the entire change.
  2. ACTIONS_STEP_DEBUG: true shows every command a step runs, useful for one failure, risky to leave on permanently.
  3. A marketplace action is a dependency. Check its update history, real usage, and security advisories before a workflow depends on it.

Further Reading and Watching