Publish artifact boundary (no source, no sourcemap)
.github/workflows/publish.yml (WU-3946, INIT-121 phase 1) closes a
distribution-artifact leak at the publish boundary: restricted
@hellmai/lumenflow-* packages were shipping complete .js.map and .d.ts.map
trees on npm, letting any licensed reader reconstruct the exact original
TypeScript source from the published tarball. This is the same failure mode
as the Claude Code March 2026 sourcemap leak (a 57MB map file, roughly
500k lines of source reconstructed from it).
Root cause
Section titled “Root cause”turbo build/turbo build:dist output collisions could re-emit
sourcemapped build output into the exact dist/ tree that the publish loop
packs, and nothing downstream verified map absence before npm publish ran.
The fix: strip, then verify (belt and braces)
Section titled “The fix: strip, then verify (belt and braces)”Two steps run in publish.yml after build and before the per-package
publish loop:
- Strip. Delete every
*.js.mapand*.d.ts.mapfile under everypackages/*/disttree. - Verify (fail-closed). Re-scan the same trees; if any map file remains, the step exits non-zero and fails the build.
The verify step exists as an independent check, not a redundant re-run of the same deletion: a future change to build order, a turbo cache replay, or a new package added to the workspace could re-emit a map file after the strip step already ran. Pairing deletion with verification means that scenario fails the build instead of silently shipping a leak again.
Guard test (workflow shape, WU-3946/WU-3947)
Section titled “Guard test (workflow shape, WU-3946/WU-3947)”tools/__tests__/publish-no-sourcemaps-guard.test.ts and
tools/__tests__/publish-no-sourcemaps-strip-verify-behavior.test.ts
originally asserted the shape and real shell behavior of the
publish.yml-embedded strip/verify steps above. The GitHub Packages
workflow they exercised is retired by the Keygen distribution migration
(WU-4060), so both files were migrated (not deleted) to pin the
registry-neutral invariant below instead — see “No source” for what they
assert now.
No source, not only no sourcemap (WU-4058)
Section titled “No source, not only no sourcemap (WU-4058)”The WU-3946/WU-3947 guard named one leak class: sourcemaps. It never
checked for raw (non-declaration) TypeScript sources or __tests__
directories, so a package that shipped no build step at all — like
@lumenflow/surfaces before this WU, which published its entire http/,
cli/, and mcp/ source tree, tests included, as .ts files — passed the
sourcemap guard while leaking complete original source.
The invariant lives in one module,
packages/@lumenflow/cli/src/release-archive-guard.ts, so the local
pre-release check and the separately owned Keygen publisher share one rule
set. It exports findSourceLeakageViolations(entries, options?), a pure,
dependency-free classifier that flags any archive entry that is:
- inside a
__tests__directory, regardless of extension; - a source map, under any extension flavor (
.js.map,.d.ts.map,.mjs.map,.cjs.map); - (default;
options.typescriptSource !== false) a non-declaration TypeScript source (.ts,.tsx,.mts,.cts—.d.ts/.d.mts/.d.ctsdeclaration files are always allowed);
plus a content-level check (hasInlineSourceMapMarker) that reads every
file — not only .js or declaration output — for an inline
sourceMappingURL=data: comment in any form a consumer honours (//#,
//@, /*#, /*@, any spacing, any case), which no name-based rule can
catch; prose that merely names the marker (the compiled guard module’s own
JSDoc did) is not a sourcemap. Names are canonicalized before classification:
./, // and /./ segments are collapsed, trailing dots and spaces are
stripped from every segment (Windows drops them on disk, so index.ts.
lands as index.ts), and __tests__ matches case-insensitively.
Exact archive, every record
Section titled “Exact archive, every record”verifyTarballHasNoSourceLeakage(tarballPath, { label }) verifies a
caller-supplied .tgz and returns { sha256, entries }. It never packs,
never runs a lifecycle hook, and never modifies or deletes the archive: the
returned sha256 is the digest of the exact bytes it inspected, so a
publisher binds the verification to the artifact it uploads by comparing
digests. It fails closed, in this order, before any content is read:
- Complete gzip stream. The whole stream is decoded and its CRC/length trailer verified; a truncated stream, a corrupted trailer, or any trailing byte after the stream is an error.
- Every archive record, in memory. Extracting first collapses
duplicate records: a real npm-produced tarball with two
package/dist/index.jsrecords — the first carrying an inline sourcemap, the last clean — was accepted by an extract-then-inspect design because only the last record survived on disk. The verifier therefore walks the POSIX ustar/PAX records directly and rejects a duplicate canonical path, compared case-insensitively (duplicate-entry), an absolute,..-traversal or control-character path (unsafe-path), a symlink or hardlink (link-entry), and any other record type (unsupported-entry). A malformed header checksum, a missing end-of-archive marker, a malformed PAX record, a PAXsizethat disagrees with the header, a dangling metadata record, or an entry named by both a PAXpathand a GNU long name (extractors disagree on which wins) is an error, never an empty or clean archive. - Names and content. Only then does the classifier above run over every record’s name (directories included), with the inline-sourcemap scan reading every file’s bytes straight from the archive. An archive with no regular-file record is refused rather than reported clean.
The workspace-side wrappers in tools/source-leakage-guard.ts reuse it:
verifyPackedPackageHasNoSourceLeakage(packageDir, { packDestination? })packs a package with the realnpm pack(real lifecycle hooks, since the published artifact is what matters) inside an isolated mirror of the workspace — every workspace entry linked into a temporary root except the package under test, which is copied — so hooks like the cli’s bundled-pack sync and the software-delivery manifest rewrite run with their siblings visible and never write to the caller’s checkout. The mirror’s copy ofpnpm-workspace.yamlsetsverifyDepsBeforeRun: false(the only place pnpm 12 reads it from), so a pnpm-driven hook cannot auto-install through the linkednode_modules; a package outside any workspace is mirrored alone. The archive is found in an owned, empty destination directory rather than parsed fromnpm pack --jsonoutput, which npm 11’s foreground hooks can corrupt. It hands the exact archive toverifyTarballHasNoSourceLeakage; withpackDestinationthe verified tarball is copied there and its path returned so a publisher can upload those same bytes, otherwise the verification is throwaway and nothing is left behind.verifyPackRootHasNoSourceLeakage(rootDir)walks a staged directory that was never packed throughnpm packwith a narrowed form of the same rules: symlinks and special files are refused (never followed or skipped), every directory and file name is classified with{ typescriptSource: false }, every file is content-scanned, and an empty root is an error. A raw.ts/.tsx/.mts/.ctsentry name is not a violation here;__tests__, source maps and inline sourcemap content still are.verifyArchivedPackRootHasNoSourceLeakage(packRoot)verifies exactly the file set the OCI content layer archives from a local pack — the kernel’s confinedlistPackFilesselection (nonode_modules, nodist/, no links), so what is checked is what is published, nothing more and nothing less — with the same{ typescriptSource: false }narrowing.
runSourceLeakageGuard() runs the packed-tarball check against every
publishable package, discovered from the workspace as every non-private
package under the pnpm-workspace globs (the same selection the release
path’s packed-consumer smoke lane uses, pinned by a parity test) rather
than a hand-maintained list, and refuses to certify a workspace where it
discovers nothing. runPackRootSourceLeakageGuard() runs the archived-set
check over every local pack root the release path publishes, discovered
through the release path’s own listLocalPackIds. Both are default,
injectable dependencies of validatePreRelease() like every other release
check, so pnpm release --dry-run refuses before any tag exists if any
package or pack would leak source. They read only the local filesystem and
each package’s own npm pack output, so they are registry-neutral:
unaffected by which registry (a self-hosted Keygen instance or none) a
package’s publishConfig.registry currently names.
Scope boundaries
Section titled “Scope boundaries”- Per-package
files/exportsshape — each package still owns its ownpackage.jsonfilesarray andexportsmap; the guard verifies the result (the packed tarball), not the manifest declaration. - Packer trust — the guard verifies the archive, not the packer. npm’s
own packer never emits a link or a
..path, but a postpack hook, a replaced archive, or a different packer can; the record audit above is what makes those cases fail instead of relying on that assumption. - Signed OCI pack distribution — see Signed OCI pack
distribution for the separate,
already-shipped pack-signing pipeline. Its content layer archives the
pack’s source directory (
listPackFilesexcludesdist/, notsrc/). Owner direction sig-91cfc529 accepts that: a local pack ships inside the licence-gated Keygen download, so raw TypeScript source there is no longer a violation (runPackRootSourceLeakageGuardclassifies with{ typescriptSource: false }). A__tests__directory remains a violation on both paths in principle, but WU-4071 excludes test directories (any nesting depth) and*.test.*/*.spec.*files from the kernel’s ownlistPackFilesselection — the same file list this guard’s pack-root path verifies against — so a local pack’s test content is never archived and never reaches the classifier in the first place. - Registry migration and workflow retirement — moving distribution off
GitHub Packages/npm to the self-hosted Keygen instance, and retiring
publish.ymlitself, are owned by the Keygen integration WUs (WU-4060/WU-4062/WU-4064), not this guard.
Related
Section titled “Related”- Release provenance — the broader fail-closed release pipeline this guard runs inside.