Missing files after npm publish: the files allowlist

Files missing from a published npm package: our vite UI build landed outside dist, so the files allowlist dropped it. Here's how I found and fixed it.

Running npx @finos/git-proxy used to give you a working proxy and a working web UI. At some point it stopped: the server boots, prints Service Listening on 8080, and then the browser gets nothing. No dashboard, no login page, just the catch-all route failing to find an index.html to hand back. The proxy half is fine. The UI is simply not in the installed package.

The confusing part is that the UI is definitely being built. GitProxy’s build script runs build-ui (a vite build) before build-ts, the release workflow runs npm run build, and that step goes green. So the assets exist on the release runner and then vanish somewhere between the build and the user’s node_modules. It turned out this had been true since 2.0.0, so two releases shipped a headless package before anyone said anything.

The setup

GitProxy (git-proxy on GitHub) ships one npm package containing both an Express server and a React UI. The server serves the built UI as static files, with a catch-all route that returns index.html so client-side routing works. The Docker image serves the UI correctly. The Cypress suite passes. Only the npm install path is broken, which is exactly the path a maintainer never uses.

Compare the tarballs, not the working tree

The first useful move is to stop reasoning about the repo and go look at what was actually published. npm pack builds the exact tarball a publish would upload, so you can inspect a release without trusting anything about your working tree:

npm pack @finos/git-proxy@1.19.2
npm pack @finos/git-proxy@2.0.0
tar tzf finos-git-proxy-2.0.0.tgz | grep -cE '\.(html|css)$'

Version 1.19.2 contains 387 files, including build/index.html and build/assets/index-d13d69b6.css, along with .github/, .vscode/, .env.production and the whole src/ tree. 2.0.0 contains 232 files and 2.1.0 contains 301, all of them under dist/. Neither 2.x tarball has a single .html, .css or assets/ file in it. That count above returns zero.

Why the UI was dropped: files is an allowlist

Two settings, in two different files, that nobody looks at together. In package.json:

"files": ["dist", "config.schema.json", "NOTICE"]

And in vite.config.ts:

build: {
  outDir: 'build',
},

Vite writes the UI to <root>/build. The allowlist names dist, so npm drops build and says nothing about it. That silence is the real problem here. npm pack does not warn you that a directory full of freshly built assets was excluded, because from npm’s side of the conversation you asked for three things and it packed three things. The npm docs are clear that files is an allowlist of what to include, with a short set of files that are always shipped and a few that never are; everything else in your tree is simply not in the package.

Version 1.19.2 worked by accident. There was no files field back then, so the entire working tree got published and build/ came along for the ride, which is also why that version shipped a .env.production and someone’s editor config. Adding files in 2.0.0 was correct packaging hygiene. It just quietly cut the UI out at the same time.

The second half: a path anchored on __dirname depth

Adding "build" to the allowlist would have shipped the assets and the server still would not have found them. The static path was resolved like this, in src/service/index.ts:

const absBuildPath = path.join(__dirname, '../../build');

In the repo, __dirname is <root>/src/service, so two levels up is <root>/build. Correct. In an installed package the compiled file sits at dist/src/service/index.js, so two levels up is <root>/dist/build. The depth matches in both layouts, the anchor does not.

The evidence that dist/build is where the server really looks was sitting in the Dockerfile the whole time:

COPY --chown=1000:1000 --from=builder /out/build ./dist/build/

The image relocates the UI by hand into exactly the place the compiled server resolves to. That one line is why the container worked and npx did not, and it is why nobody noticed for two releases: Docker was papering over the mismatch on the only path the maintainers exercised.

The fix: make dist/build the canonical location

Rather than keep a Docker-only relocation, put the output where production expects it. One line in vite.config.ts:

build: {
  outDir: 'dist/build',
},

emptyOutDir only clears the directory vite actually owns, so this wipes dist/build and leaves dist/src and the rest of the tsc output alone. That also makes the script order safe in both directions, and files: ["dist", ...] picks the UI up with no further change.

Then stop counting directory levels. Anchor on the package root instead, in a new src/service/paths.ts:

function findPackageRoot(from: string = __dirname): string {
  let dir = from;
  for (;;) {
    if (fs.existsSync(path.join(dir, 'package.json'))) return dir;
    const parent = path.dirname(dir);
    if (parent === dir) throw new Error('Could not locate GitProxy package root');
    dir = parent;
  }
}

export const UI_BUILD_PATH = path.join(findPackageRoot(), 'dist', 'build');

From src/service in the repo and from dist/src/service in an install, that walk lands on the same package root, so both resolve to <root>/dist/build. src/service/index.ts then sets absBuildPath from UI_BUILD_PATH instead of computing it. The emit is CommonJS (module: NodeNext with no "type": "module"), so __dirname is available here. Worth knowing if vite ever nags you that vite.config.ts uses ESM syntax in a file loaded as CommonJS: take the .mts rename, not the "type": "module" suggestion, because that field is what decides the module system for every .js in the package, so it flips the whole emit to ESM and __dirname disappears out from under this helper. Do check that dev mode still works after this change, since the two layouts resolve differently and it is easy to fix the published path while quietly breaking your own. Mine refused to start on the first try for reasons that turned out to have nothing to do with any of this, namely an inotify limit reported as ENOSPC.

Two more small things go with it. The COPY line in the Dockerfile has to be deleted, not merely can be, because /out/build no longer exists and the image build fails hard. And a missing UI degrades into a bare 404, which is precisely how this got out the door twice, so it is worth being loud about at startup:

if (!fs.existsSync(path.join(absBuildPath, 'index.html'))) {
  console.error(`[git-proxy] UI assets not found at ${absBuildPath}. The package was built or published incorrectly.`);
}

A test that looks at the artifact

None of the existing tests could have caught this, because every one of them runs against the repo, where build/ is right there on disk. The gap is the artifact, so the test has to pack it, install it somewhere else, and boot it the way a user would:

TARBALL="$WORK/$(npm pack --silent --pack-destination "$WORK")"
FILES="$(tar tzf "$TARBALL")"

if ! grep -qx 'package/dist/build/index.html' <<<"$FILES"; then
  echo "FAIL: dist/build/index.html is missing from the tarball."
  grep '^package/dist/build' <<<"$FILES" || echo "(dist/build is entirely absent)"
  exit 1
fi

cd "$WORK" && npm init -y >/dev/null
npm install --no-audit --no-fund --loglevel=error "$TARBALL"
"$WORK/node_modules/.bin/git-proxy" > "$WORK/server.log" 2>&1 &

HTML="$(curl -fsS http://localhost:8080/)"
ASSET="$(grep -oE '/assets/[A-Za-z0-9._-]+\.js' <<<"$HTML" | head -1)"
curl -fsS -o /dev/null "http://localhost:8080$ASSET" || { echo "FAIL: $ASSET 404s."; exit 1; }

That last pair of lines is the assertion I care about most. Serving index.html only proves the directory exists; fetching the hashed bundle that index.html actually references proves vite’s assets/ output came along too. A half-copied build passes the first check and fails the second.

Two ways the smoke test lied: a stale dist and pipefail

The first run reported the same failure on the fixed branch as on main, which is impossible if the script is testing what it claims to test, since main has no dist/build at all. It was not building anything. It packs whatever is sitting in dist/ on disk, and dist is gitignored, so git checkout never touches it and yesterday’s output survives every branch switch. Locally the script needs rm -rf dist build && npm run build before it packs. In CI a fresh checkout cannot be stale, so a --no-clean-build flag skips the rebuild there. If you add that flag, read it as "${1:-}" rather than "$1", or set -u kills the script on line five with $1: unbound variable the moment someone runs it with no arguments.

The second run was stranger. The check failed and then the diagnostic output printed the very file it said was missing:

FAIL: dist/build/index.html is missing from the tarball.
--- everything under dist/build ---
package/dist/build/assets/index-BDruNbBq.css
package/dist/build/index.html

This is set -o pipefail meeting grep -q. grep -q exits immediately with zero status the instant it finds a match, tar is still writing into the closed pipe, takes SIGPIPE and dies with status 141, which is bash reporting 128+n for a command killed by signal n with SIGPIPE being 13. Then pipefail throws away grep’s zero: the pipeline takes the status of the rightmost command that exited non-zero, and here the only one that did is tar. So a successful match comes back as 141 and if ! runs the failure branch. The diagnostic grep underneath has no -q, so it drains the whole stream, tar never gets SIGPIPE, and it happily prints the file. Reading the listing once into a variable and matching against a here-string, as in the snippet above, removes the pipe entirely. Keep pipefail: the setting is right, the pipe was the problem. And note this failure is a race, not a constant, so on a smaller tarball the same check would go green some runs and red others, which is about the worst behaviour a release gate can have.

Worth adding a port pre-flight too, so the script refuses to run when something is already listening on 8080. Otherwise a git-proxy that dies on EADDRINUSE leaves every assertion running against whatever else is there. A fixed port in a test is a recurring nuisance: in the test suite proper I ended up handing port selection to the OS to stop two files colliding, though a smoke test that curls a URL needs a port it can predict, so here the answer is to check first and bail. Ours fired on the first CI run, correctly: an earlier Cypress step in the same job had left npm start running, and steps share a runner session, so the check would have been testing a dev-mode server built from the working tree. Giving packaging its own workflow on a clean runner is simpler than fighting for the port.

Finally, wire it into the release path, between the build and the publish, in the same workflow I moved over to npm trusted publishers:

- run: npm ci
- run: npm run build
- name: Smoke-test the package before publishing
  run: bash ./scripts/package-smoke-test.sh --no-clean-build

Calling it through bash rather than as ./scripts/package-smoke-test.sh is deliberate. The first CI run of that step never got past line one, failing with Permission denied and exit code 126, because the executable bit never made it into the commit. A gate that guards a publish should not be able to fail for a reason like that.

The takeaway

files in package.json is an allowlist, and npm will never tell you what it left out. Any build step that writes outside the allowlisted directories is one release away from shipping nothing, and if something like a Dockerfile copies that output into place by hand, the environment you test in keeps working while the one your users install stays broken. So check the tarball, and check it from a scratch install rather than from the repo. npm pack plus tar tzf is a ten-second habit that would have caught this before the first bad release, never mind the second.