DEV Community

Cover image for My Committed Bundle Went Stale: CI Rebuilt It and Never Compared
HideyukiMORI
HideyukiMORI

Posted on

My Committed Bundle Went Stale: CI Rebuilt It and Never Compared

I found out that a bundle committed to one of my repos had gone stale because an unrelated PR left my git tree dirty.

Not a test. Not a review. A dirty tree, noticed by accident.

The setup

nene-concierge is one of my self-hosted business tools. Its repo commits a built frontend artifact: public_html/admin/app.js, the admin bundle, produced by esbuild via npm run build. The public_html/ tree is the deployable root, generated files included.

Committing build output is a choice with a known tradeoff. I knew the tradeoff. I just wasn't paying for it.

What broke

An earlier refactor (#178/#179) added "type": "module" to frontend/package.json.

That one line changed esbuild's CJS interop output. Every helper call in the bundle went from __toESM(x) to __toESM(x, 1). From that day on, the app.js sitting in main no longer matched what a clean build produces — a 573-insertion diff of pure drift.

Nothing failed. Type-check green. Lint green. Tests green. Nothing in the pipeline had any opinion about that file.

Here's the detail that stings most. My CI runs npm run check, and check includes build. So CI was rebuilding that bundle on every single run — producing the correct, fresh output in its own workspace — and then throwing it away without ever comparing it to the file committed two directories over.

A committed artifact is a claim about your source code, and nothing was checking the claim.

How it surfaced

By luck. While verifying an unrelated PR (#183, a lint-suppressions ratchet gate), I ran the full check locally and git status came back dirty: npm run build had rewritten public_html/admin/app.js under me.

That PR's body records it as an explicitly out-of-scope finding, the artifact was restored so the ratchet PR stayed clean, and the drift got its own issue: #185.

If that ratchet PR hadn't happened to run a build on my machine, I have no idea when I'd have noticed.

The fix — and what the fix doesn't fix

PR #186 (merged as b7eb1ea) is deliberately boring: run npm run build, commit the output as-is. Machine diff only, zero source changes, two generated files touched (app.js and its sourcemap).

Two things made me trust it:

  • Idempotency: two independent builds, byte-identical output. If your bundler embeds timestamps or random chunk names, a freshness check is off the table until that's fixed — so prove determinism first.
  • Clean tree after: rebuild, then git status shows nothing. That was the acceptance criterion on the issue.

But notice what this fix is: a resync. It repairs this drift and does nothing about the next one. Any future change that shifts compiler output — a bundler upgrade, a tsconfig flag, another package.json line — re-creates the exact same silent lie.

The actual lesson: freshness must be a test

If a build artifact lives in git, CI has to prove it's fresh. The check is about four lines:

- run: npm ci && npm run build
  working-directory: frontend
- name: Committed artifacts must match a clean build
  run: |
    test -z "$(git status --porcelain)" || {
      git status; git diff | head -50
      echo "::error::Committed build output is stale. Run npm run build and commit."
      exit 1
    }
Enter fullscreen mode Exit fullscreen mode

Build from a clean checkout, then assert the tree is still clean. That's it. It would have turned this whole story into a red X on the PR that added "type": "module", instead of a discovery-by-accident later.

Full honesty: as I write this, that gate is not yet in nene-concierge's workflow — the resync is merged, the guard is the follow-up. I checked the workflow file before writing this paragraph, because claiming a fix you haven't shipped is exactly the kind of lie this article is about.

And the cleaner alternative deserves saying out loud: if you can avoid committing artifacts at all — build in CI, build at deploy — do that. An artifact that's never committed can't go stale in the repo. Committing output is sometimes the pragmatic choice when the deploy target is a plain host that just serves files; that convenience is fine, but it isn't free. The freshness check is the price.

Takeaways

  • A committed artifact is an unverified claim. CI must rebuild and git status --porcelain-assert it, or the artifact will eventually lie.
  • Toolchain config changes ("type": "module", bundler bumps) can change output while every semantic check stays green — drift needs no bug to happen.
  • Prove build determinism (two builds, byte-identical) before you rely on any freshness comparison.

Primary sources

  • nene-concierge issue #185 and PR #186 (merge b7eb1ea) — the drift and the resync
  • nene-concierge PR #183 — the out-of-scope note where the drift was first recorded

Related: Click a link, get a throwaway tenant: a zero-signup demo for a self-hosted app

Do you commit build output anywhere — and if so, what would actually fail today if it went stale? If the answer is "nothing," I'd check.

── Hideyuki Mori (Ayane International) 🔗 hideyuki-mori.com

Top comments (0)