Never Commit What CI Is Going to Regenerate Anyway

Why node_modules, dist, and every other build output belong in .gitignore, and why I commit source and pipeline setup as two separate steps.

September 7, 20263 min read2 / 4

Every job in this pipeline starts on a fresh virtual machine with nothing carried over from the last run. That single fact decides what belongs in git and what doesn't.

If CI is going to regenerate something from scratch every time anyway, committing it just gives you a second, stale copy to keep in sync.

What actually needs to be tracked

Run git status right after installing dependencies and building a fresh project, and three directories show up as untracked: node_modules/, dist/, and whatever local build cache your framework keeps, .astro/ for an Astro project, .next/ for Next.js, and so on.

None of them belong in the repo.

📦node_modules
🏗️dist
🗂️.astro / framework cache
Plain text
node_modules dist .astro

node_modules gets rebuilt from package.json and the lockfile on every install. dist gets rebuilt from source on every build. The framework cache gets rebuilt the moment the framework runs. Every one of these is a computed output, not a fact about your project.

What happens if you commit them anyway

Committing node_modules is the classic version of this mistake, and most people who've done it once never do it again. A single dependency bump produces a diff with thousands of changed files, none of which anyone can meaningfully review.

dist causes a quieter version of the same problem. Every build overwrites it, so every build creates a new commit's worth of noise, and your history stops describing what changed and starts describing when someone last ran npm run build.

A build artifact that gets regenerated from a clean checkout has no business living in the same history as the source that produces it.

There's one real exception worth knowing about: some GitHub Actions are themselves JavaScript, and GitHub loads them by running the committed dist/ directly, no build step available at that point. That's a deliberate, narrow exception for actions distributed through the Marketplace, not a reason to commit dist in an application repo.

Commit your source before you wire up CI

There's a smaller habit worth adopting alongside .gitignore, one that has nothing to do with what files exist and everything to do with the order you commit them in.

Set up the source code, get it building locally, commit that. Only then start wiring up the GitHub Actions workflow, and commit that separately.

If something breaks the moment CI is added, you already know which half is responsible. Mixing "the app works" and "the pipeline works" into a single commit means a red check leaves you debugging both at once, with no way to tell which one actually broke.

This costs nothing. It's the order you were probably going to do the work in anyway, just with a commit boundary drawn between the two halves instead of one commit covering both.

It's the same instinct as keeping build and deploy in separate jobs: when two things can fail independently, giving them separate boundaries is what lets you tell them apart when one of them does.

The Essentials

  1. A committed node_modules turns every dependency bump into an unreviewable diff. It's the single most common version of this mistake.
  2. A committed dist quietly pollutes your history with a new commit's worth of noise on every build. The one narrow exception is a Marketplace GitHub Action, which loads its own committed dist directly.
  3. Commit source and CI setup as two separate steps. If something breaks right after wiring up the pipeline, you'll already know which layer to look at first.

Further Reading and Watching