Automation/CI

Ship It 1/3: From Laptop to a Reproducible Build

Ship It, part one: before automating anything else, I had to fix the fact that my build only worked reliably on my own laptop.

This is part one of a three-part series on turning a project that only ever shipped from my laptop into one with a real, boring, repeatable path to production. Before touching deployment or release scripts, I had to fix something more basic: the build itself only reliably worked on my machine.

"Works on My Laptop" Is Not a Build Process

The symptom was familiar — a teammate would pull the repo, run the build, and hit a dependency resolution error that never showed up for me. The cause, once I actually looked, was that I had global tool versions installed locally that the project quietly depended on without declaring anywhere.

Pinning What Actually Matters

The first fix was pinning the runtime version explicitly instead of trusting whatever was globally installed:

# .node-version
20.11.1
# excerpt from the CI workflow
- uses: actions/setup-node@v4
  with:
    node-version-file: '.node-version'
    cache: 'npm'

Reading the version from a single committed file means CI, my laptop, and anyone else's laptop are all guaranteed to use the same runtime, rather than "whatever happened to be installed when this was set up."

Locking Dependencies for Real

The second gap was that package-lock.json existed but CI was running npm install instead of npm ci. install will happily update the lockfile to satisfy loosely-specified ranges; ci refuses to run at all if the lockfile and package.json disagree, which turns a silent drift into a loud, immediate failure:

- run: npm ci
- run: npm run build
- run: npm test

That one-word change — install to ci — caught two dependency mismatches in the first week alone, both of which would previously have shipped silently and only surfaced as a weird bug days later.

Making the Build Itself Deterministic

The build script had one more subtle issue: it embedded the current timestamp into a generated file, which meant two builds of the identical source code produced different output. That's harmless for a simple app, but it broke build caching and made it impossible to verify that a deployed artifact matched a specific commit.

// before — different output on every build, even with no code changes
const BUILD_ID = Date.now().toString();

// after — derived from the actual source, stable across identical commits
const BUILD_ID = process.env.GIT_COMMIT_SHA ?? 'local';

Passing the commit SHA in as an environment variable instead of generating a fresh timestamp meant CI could cache build output keyed on the commit, and meant "what code is actually running in production" became an answerable question instead of a guess.

What's Next

None of these three changes were individually dramatic — pinning a runtime version, switching one npm command, deriving a build ID from a commit SHA instead of a timestamp. What made them worth writing up together is that they share the same underlying fix: replace something implicit and machine-dependent with something explicit and committed to the repository. That pattern turned out to generalize to almost everything else in this series.

With the build itself finally reproducible — same input, same output, on any machine — the next problem was everything around the build: the checklist of manual steps I ran before every release. That's part two, on turning that checklist into a script, and the series finishes with actually getting it running in production.