Skip to content

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).

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:

  1. Strip. Delete every *.js.map and *.d.ts.map file under every packages/*/dist tree.
  2. 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.

- name: Strip sourcemaps from publishable dist trees
  run: |
    find packages -type d -name dist -print0 | xargs -0 -I{} find {} -type f \( -name '*.js.map' -o -name '*.d.ts.map' \) -delete

- name: Verify no sourcemaps remain in publishable dist trees
  run: |
    remaining=$(find packages -type d -name dist -print0 | xargs -0 -I{} find {} -type f \( -name '*.js.map' -o -name '*.d.ts.map' \))
    if [ -n "$remaining" ]; then
      echo "::error::Sourcemap file(s) survived the strip step and would leak in a published package:" >&2
      echo "$remaining" >&2
      exit 1
    fi

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.cts declaration 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.

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:

  1. 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.
  2. Every archive record, in memory. Extracting first collapses duplicate records: a real npm-produced tarball with two package/dist/index.js records — 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 PAX size that disagrees with the header, a dangling metadata record, or an entry named by both a PAX path and a GNU long name (extractors disagree on which wins) is an error, never an empty or clean archive.
  3. 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 real npm 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 of pnpm-workspace.yaml sets verifyDepsBeforeRun: false (the only place pnpm 12 reads it from), so a pnpm-driven hook cannot auto-install through the linked node_modules; a package outside any workspace is mirrored alone. The archive is found in an owned, empty destination directory rather than parsed from npm pack --json output, which npm 11’s foreground hooks can corrupt. It hands the exact archive to verifyTarballHasNoSourceLeakage; with packDestination the 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 through npm pack with 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/.cts entry 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 confined listPackFiles selection (no node_modules, no dist/, 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.

  • Per-package files/exports shape — each package still owns its own package.json files array and exports map; 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 (listPackFiles excludes dist/, not src/). 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 (runPackRootSourceLeakageGuard classifies 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 own listPackFiles selection — 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.yml itself, are owned by the Keygen integration WUs (WU-4060/WU-4062/WU-4064), not this guard.