newerkey notes
// devops · 1 July 2026 · 4 min read

Caching Dependencies and Cleaning Up After a GitHub Actions Pipeline

Two small additions to the deploy workflow: skip npm ci when nothing changed, and automatically delete old workflow runs after a successful production deploy.

newerkey github-actions caching ci-cd automation

Once the deploy pipeline was working end to end, two small annoyances were left: every push re-installed the same dependencies from scratch, and the Actions tab was filling up with runs I’d never look at again. Neither was urgent. Both were cheap to fix once.

Skipping the install when nothing changed

actions/setup-node has a built-in cache: npm option, which caches npm’s download cache — it still runs npm ci and reinstalls every package from that local cache into node_modules on every run. That’s faster than hitting the registry, but it isn’t free.

What I wanted was to skip npm ci entirely when package-lock.json hasn’t changed since the last run. actions/cache does that directly by caching node_modules itself and reporting back whether it found a match:

- name: Setup Node.js
  uses: actions/setup-node@v4
  with:
    node-version: '20'

- name: Cache node_modules
  id: cache-modules
  uses: actions/cache@v4
  with:
    path: node_modules
    key: node-modules-${{ runner.os }}-${{ hashFiles('package-lock.json') }}

- name: Install dependencies
  if: steps.cache-modules.outputs.cache-hit != 'true'
  run: npm ci

The cache key is a hash of package-lock.json. Same lockfile, same key, same cache hit — and the if: condition on the install step means npm ci doesn’t run at all. Change one dependency, the hash changes, the cache misses, and it reinstalls cleanly. No manual cache invalidation to remember.

Deleting old runs automatically

The Deploy workflow fires on every push to staging or main. After a few weeks that’s a long list of runs in the Actions tab, almost all of them superseded by whatever shipped after them. I didn’t want to prune that by hand.

A second workflow watches the first one and cleans up after it — but only after a successful deploy to main, so a run never gets deleted before I know whether it actually worked:

name: Cleanup old runs

on:
  workflow_run:
    workflows: ["Deploy"]
    branches: [main]
    types: [completed]

permissions:
  actions: write

jobs:
  cleanup:
    if: github.event.workflow_run.conclusion == 'success'
    runs-on: ubuntu-latest
    name: Delete old workflow runs

    steps:
      - name: Delete old runs
        uses: actions/github-script@v7
        with:
          script: |
            const { data: { workflows } } = await github.rest.actions.listRepoWorkflows({
              owner: context.repo.owner,
              repo: context.repo.repo,
            });

            for (const workflow of workflows) {
              const { data: { workflow_runs } } = await github.rest.actions.listWorkflowRuns({
                owner: context.repo.owner,
                repo: context.repo.repo,
                workflow_id: workflow.id,
                per_page: 100,
              });

              const toDelete = workflow_runs.slice(1);
              await Promise.all(
                toDelete.map(run =>
                  github.rest.actions.deleteWorkflowRun({
                    owner: context.repo.owner,
                    repo: context.repo.repo,
                    run_id: run.id,
                  }).catch(() => {})
                )
              );
            }

Two details matter here. First, workflow_run is a separate trigger from push — this job runs in its own context, after the Deploy workflow has already finished, so it can check github.event.workflow_run.conclusion before touching anything. Second, it loops over every workflow in the repo, not just Deploy, and keeps only the single most recent run of each (.slice(1) drops everything after the first, most-recent entry) — so Cleanup also prunes its own run history without a special case for itself.

The default GITHUB_TOKEN can’t delete workflow runs unless you say so explicitly, which is why permissions: actions: write is declared at the top level rather than assumed.

Why bother with either

Neither change makes the site ship meaningfully faster or the repo meaningfully more usable. They’re the kind of maintenance that’s easy to defer forever because nothing is actually broken. The reason to do them anyway is the same reason the whole pipeline is one readable file: a system that stays cheap to operate is a system you keep operating correctly. Small friction, left alone, is how pipelines quietly stop getting maintained.


Part of my DevOps series — documenting real tasks with the commands, the reasoning, and the principles behind each decision.

newerkey github-actions caching ci-cd automation

// newerkey notes

Engineering notes on Linux, infrastructure, automation, and platform systems — written as I learn and build.

about these notes