fix: guard publish against a half-built dist

The build is `tsup && tsc`. tsup runs with clean:true, so it wipes dist and
writes JavaScript; if tsc then fails, dist holds .js with no declarations. The
build exits non-zero, but packages/core declared no prepublishOnly, so nothing
rebuilt at publish time and a later npm publish would ship it.

Reproduced: remove the declarations from a copy of dist and npm pack --dry-run
still succeeds, with all 41 exports subpaths pointing at files absent from the
tarball. npm pack was never the guard.

Adds prepublishOnly (build then verify) and scripts/verify-dist.mjs, which
asserts every exports target exists. CI runs verify-dist as well, so the guard
is exercised on every push rather than only on the rare publish.
This commit is contained in:
Paul Logan 2026-07-27 15:12:13 -07:00
parent 6f1f8a4462
commit 0ec9ea93cb
3 changed files with 60 additions and 1 deletions

View file

@ -35,6 +35,9 @@ jobs:
- name: Build core
run: npm run build --workspace=@codeburn/core
- name: Verify every export target exists
run: npm run verify-dist --workspace=@codeburn/core
- name: Verify package contents
run: npm pack --workspace=@codeburn/core --dry-run

View file

@ -181,7 +181,9 @@
"build": "tsup && tsc -p tsconfig.build.json",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"emit-schemas": "tsx scripts/emit-schemas.mts"
"emit-schemas": "tsx scripts/emit-schemas.mts",
"verify-dist": "node scripts/verify-dist.mjs",
"prepublishOnly": "npm run build && npm run verify-dist"
},
"engines": {
"node": ">=22.13.0"

View file

@ -0,0 +1,54 @@
#!/usr/bin/env node
// Refuses to let a half-built dist reach npm.
//
// `npm run build` is `tsup && tsc`. tsup runs with clean:true, so it wipes dist
// and writes JavaScript; if tsc then fails, dist holds .js with no declarations.
// The build exits non-zero, but nothing rebuilds at publish time, so a later
// `npm publish` would ship a package whose every `types` target 404s. Verified:
// removing the declarations and running `npm pack --dry-run` succeeds with
// 41/41 exports subpaths pointing at files that are not in the tarball.
//
// This script is the assertion that closes that gap. It runs from
// `prepublishOnly` (after the build) and in CI, so it is exercised on every
// push rather than only on the rare publish.
import { existsSync, readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'
const pkgRoot = join(dirname(fileURLToPath(import.meta.url)), '..')
const pkg = JSON.parse(readFileSync(join(pkgRoot, 'package.json'), 'utf8'))
const problems = []
let checked = 0
for (const [subpath, entry] of Object.entries(pkg.exports ?? {})) {
for (const condition of ['import', 'types']) {
const relative = entry?.[condition]
if (!relative) {
problems.push(`exports["${subpath}"] declares no "${condition}" target`)
continue
}
checked++
if (!existsSync(join(pkgRoot, relative))) {
problems.push(`exports["${subpath}"].${condition} -> ${relative} does not exist`)
}
}
}
// `files` decides what actually ships; a target outside it is unreachable to a
// consumer even when it exists on disk here.
const shipped = pkg.files ?? []
if (!shipped.includes('dist')) {
problems.push('package.json#files does not include "dist", so no export target would ship')
}
if (problems.length > 0) {
console.error(`dist verification failed (${problems.length} problem(s)):`)
for (const problem of problems) console.error(` - ${problem}`)
console.error('\nRun `npm run build --workspace=@codeburn/core` and re-check.')
process.exit(1)
}
console.log(
`dist verified: ${checked} export targets across ${Object.keys(pkg.exports ?? {}).length} subpaths all present`,
)