Skip to content

Fix npm publish to ship compiled dist binary - #152

Merged
grimicorn merged 1 commit into
mainfrom
agent/publish-ship-dist
Sep 8, 2026
Merged

Fix npm publish to ship compiled dist binary#152
grimicorn merged 1 commit into
mainfrom
agent/publish-ship-dist

Conversation

@grimicorn-agent

Copy link
Copy Markdown
Collaborator

What changed and why

package.json declared bin.markpost as ./dist/index.js, but dist/ is gitignored, there was no files allowlist, no .npmignore, and no prepublishOnly/prepack build step. npm publish therefore packed the repo's source tree (src/, tests/) instead of dist/, shipping a package whose declared binary doesn't exist.

  • Added files: ["dist"] so the published tarball only contains dist/ (plus npm's always-included package.json/README.md).
  • Added prepack: clean dist/ (portable node:fs rmSync, no new dependency needed) then npm run build. Used prepack rather than prepublishOnly because prepack also fires on plain npm pack — the exact command used to verify this fix — and on git-based installs; prepublishOnly only fires on npm publish.
  • Added postpack: assert dist/index.js exists on disk after packing, so a skipped-scripts publish fails loudly instead of silently shipping an empty package.
  • Added tests/package.test.ts with three tests:
    1. package.json's files field allowlists every bin target's top-level directory (handles bin as a map or string, and files entries like dist/, /dist, dist/**).
    2. The prepack/prepublishOnly hook actually removes that directory (rm -rf, rimraf, or rmSync) before rebuilding — not just mentioning the directory name.
    3. A real end-to-end check: runs npm pack --dry-run --json (which triggers the real prepack/build lifecycle) and asserts every bin target is actually present in the packed file list.

Verification

  • npm run build produces dist/index.js.
  • npm pack (not dry-run) produces a tarball containing package/dist/index.js.
  • Before: npm pack --dry-run → 76 files, 190.2 kB, no dist/. After: 54 files, 81.0 kB, dist/* only (no src//tests/).
  • npm run lint, npm run typecheck, and npm run test:ci (840 tests, 29 files) all pass.

Where to view

  • package.json (root)
  • tests/package.test.ts

No new env vars, services, or external setup required.

Closes #146

package.json declared bin markpost=./dist/index.js but dist/ is
gitignored with no files allowlist and no build hook before publish,
so npm publish packed src/ and tests/ instead of dist/, shipping a
package with no working binary.

- Add a files field allowlisting dist so it's the only source included
  in the published tarball (verified with npm pack: 76 files / 190.2kB
  before, 54 files / 81.1kB after, now containing dist/* including
  dist/index.js instead of src/tests).
- Add prepack: clean dist/ (portable node fs.rmSync, no new dependency)
  then npm run build, so a stale file from a deleted/renamed source
  module never survives into the tarball. prepack (not prepublishOnly)
  so it also covers npm pack, the exact command used to verify this.
- Add postpack: assert dist/index.js exists, so a skipped-scripts
  publish fails loudly instead of shipping an empty package.
- Add tests/package.test.ts asserting package.json keeps every bin
  target's directory in files, and a prepack/prepublishOnly hook that
  cleans that directory before running the build, so this can't
  silently regress. Derives the checks from bin/files entries (handles
  bin as a map or string, and files entries like dist/, /dist, dist/**)
  rather than hardcoding 'dist', so the guard still holds if the output
  directory or files syntax ever changes.

Closes #146
@grimicorn-agent

Copy link
Copy Markdown
Collaborator Author

Independent code review trail (3 rounds)

Round 1 — flagged:

  1. prepublishOnly doesn't fire for npm pack or git installs → fixed: switched to prepack.
  2. Test hardcoded 'dist' instead of deriving from binfixed: binRootDirectories() derives it.
  3. files: ["dist"] might drop a non-compiled runtime asset → checked, not applicable: the only non-.ts file under src/ is src/types/vendor/manifest.json, which is provenance metadata read only by the dev-only scripts/sync-contract.mjs, never imported by the CLI at runtime. Nothing outside dist is needed to run the binary.
  4. tsc doesn't clean stale outputs from deleted/renamed source files → fixed: prepack now does rmSync('dist', {recursive:true,force:true}) before npm run build (verified: a stray dist/stale-file.js is removed by npm run prepack).

Round 2 — flagged:

  1. rm -rf isn't Windows-portable → fixed: switched to a portable node -e "require('node:fs').rmSync(...)" call rather than adding a new rimraf dependency for a one-line use.
  2. Test asserted the literal string rm -rf dist, contradicting its own "survive a rename" design and breaking once the clean command changed → fixed: replaced with a regex requiring an actual removal call (rm -rf, rimraf, or rmSync) against the derived bin root.
  3. Test passes vacuously if bin resolves to zero targets → fixed: added expect(binRoots.length).toBeGreaterThan(0).
  4. toContain('dist') is too strict for equivalent files syntax (dist/, /dist, dist/**) → fixed: added normalizeFilesEntry().
  5. bin as a bare string (npm shorthand) breaks Object.valuesfixed: binTargets() handles both the map and string forms.
  6. files: ["dist"] converts a missing build into a silent empty-package publish if scripts are skipped → fixed: added postpack asserting dist/index.js exists.

Round 3 — flagged:

  1. The "clean before build" regex still just checked indexOf(binRoot), so "echo dist && npm run build" would pass → fixed: replaced with cleanCommandPattern(), a regex requiring rm -rf/rimraf/rmSync specifically (verified manually: matches the real hook, rejects an echo dist bypass).
  2. The "clean and rebuild" test only checked the first bin root when there could be several → fixed: now loops over every bin root, same as the files test.
  3. postpack's accessSync only checks the working tree, not the tarball itself, so it can't catch a files entry that's present but doesn't actually match what gets packed → fixed differently: added a third test that runs the real npm pack --dry-run --json (which triggers the actual prepack/build lifecycle) and asserts every bin target is present in the packed file list — this is the genuine end-to-end check and directly satisfies the issue's "verify with npm pack --dry-run" acceptance criterion as an automated regression test, so kept the simpler postpack existence check as-is (cheap, catches the common "empty dist" case) rather than complicating it with a nested pack invocation.
  4. Suggested making the whole test suite "prove the artifact is correct end-to-end" rather than just configuration → addressed by the same new integration test in Add deterministic security-scanner layer #3.
  5. normalizeFilesEntry mishandled negated (!dist/**) and bare-wildcard (**/*) files entries → fixed the negation case (dropped rather than normalized); left bare-wildcard entries alone since files: ["**/*"] would defeat the entire allowlist this PR exists to add, and isn't a realistic direction for this package to take.
  6. prepack deleting dist/ on every npm pack/git install could surprise a developer mid-debug session or a long-running process reading from distskipped, accepted tradeoff: reviewer's own conclusion was this is an acceptable trade for publish correctness; --ignore-scripts remains the intentional escape hatch, and the new postpack check + tests exist precisely to make the normal path safe.

Unresolved after 3 rounds: none outstanding — every actionable finding from round 3 was fixed or has a stated reason above.

@grimicorn
grimicorn merged commit d7553c3 into main Sep 8, 2026
2 of 3 checks passed
@grimicorn
grimicorn deleted the agent/publish-ship-dist branch September 8, 2026 00:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

npm publish ships no dist, so the installed markpost binary is missing

2 participants