Failing the Build When Source Maps Are Missing

Silent symbolication failures accumulate undetected until an incident makes the missing maps obvious. This page shows how to add a hard validation gate to your CI pipeline that fails the job — not just logs a warning — whenever a JS chunk lacks a matching .map file or the Sentry artifact count mismatches the local chunk count. It extends the upload stage in CI/CD Source Map Upload and Validation, which is part of Source Map Generation & Stack Trace Debugging.

Source Map Validation Gate LogicBuild output feeds Count Local JS Chunks (N) and Count Remote Maps (M); a decision checks whether N equals M. The yes path leads to a green PASS box, the no path leads to a red FAIL exit 1 box that blocks the CDN deploy.Build Outputdist/*.js + *.mapCount Local JSfind *.js → NCount Remote MapsSentry API → MN = M?PASSFAIL — exit 1blocks CDN deployyesno

Symptom / Trigger

The CI pipeline completes without error, the bundle ships to the CDN, and Sentry events start arriving — but the stack traces remain minified. Checking the Sentry artifact panel reveals zero .map files or only a subset of the expected chunks:

# sentry-cli releases files $SENTRY_RELEASE list
Name                                Size
app.4f9a1b2c.js                    142 KB
vendor.8e3a2f10.js                  890 KB
# missing: app.4f9a1b2c.js.map and vendor.8e3a2f10.js.map

No CI job failed. The upload step ran but uploaded zero files because the --url-prefix path was wrong and sentry-cli exited 0 despite finding no matching artifacts in ./dist.

Root Cause Explanation

sentry-cli sourcemaps upload exits 0 even when it uploads zero files. The command is designed to be idempotent — uploading the same file twice is not an error — so a misconfigured path that matches nothing is also not an error from the CLI’s perspective:

# BROKEN: path typo means no files are found — but exits 0
npx sentry-cli sourcemaps upload \
  --release "$SENTRY_RELEASE" \
  --url-prefix "https://cdn.example.com/static/" \
  ./build                     # actual output is ./dist, not ./build — no files matched
# Exit code: 0
# Output: "Uploaded 0 new files"

The pipeline proceeds, CDN sync runs, traffic shifts, and every error in Sentry is now permanently unsymbolicated for this release.

The deeper mechanism is that sentry-cli resolves the upload path, globs for matching artifacts, and reports success on the transaction — the network round trip that registered the release — not on the cardinality of what was sent. A release with zero artifacts is a perfectly valid API state, so the server returns 200 and the CLI mirrors that with exit 0. Because the release object itself is created, the Sentry UI even shows the release as present, which makes the failure harder to spot: the dashboard looks healthy while the artifact list is empty. The only reliable signal is an explicit post-upload count assertion, which no bundler plugin or CLI flag performs for you by default.

The diagram below contrasts a pipeline that trusts that exit code with one that adds the count assertion described in this how-to.

CI Without and With a Map Validation GateBefore adding the gate, sentry-cli exits zero on an empty upload, minified traces ship, and the gap stays hidden until an incident. After adding the gate, CI asserts the local and remote counts match, exits one on a mismatch, and blocks the deploy early.No gateEmpty upload exits 0Minified traces shipHidden until incidentWith gateAsserts N = MExit 1 on mismatchDeploy blocked early

Step-by-Step Fix

1. Count local JS chunks before the upload

#!/usr/bin/env bash
# scripts/count-chunks.sh
set -euo pipefail

# Count non-map JS files produced by the build
LOCAL_JS=$(find ./dist -name '*.js' ! -name '*.map' -type f | wc -l | tr -d ' ')
echo "LOCAL_JS_CHUNKS=$LOCAL_JS" >> "$GITHUB_ENV"   # expose to later steps
echo "Found $LOCAL_JS local JS chunks"

if [ "$LOCAL_JS" -eq 0 ]; then
  echo "ERROR: No JS chunks found in ./dist — did the build step fail silently?"
  exit 1
fi

Run this immediately after npm run build completes, before the upload step. It catches a failed or empty build before any upload attempt.

2. Fail on zero maps uploaded — wrap the sentry-cli call

#!/usr/bin/env bash
# scripts/upload-and-verify.sh
set -euo pipefail

RELEASE="$SENTRY_RELEASE"
LOCAL_COUNT="${LOCAL_JS_CHUNKS}"          # from the count step above

# Run the upload
npx sentry-cli sourcemaps upload \
  --release "$RELEASE" \
  --url-prefix "https://cdn.example.com/static/" \
  ./dist

# Immediately query the artifact list and count maps
REMOTE_COUNT=$(npx sentry-cli releases files "$RELEASE" list --output json 2>/dev/null \
  | python3 -c "
import sys, json
files = json.load(sys.stdin)
maps = [f for f in files if f['name'].endswith('.map')]
print(len(maps))
")

echo "Local chunks : $LOCAL_COUNT"
echo "Remote maps  : $REMOTE_COUNT"

if [ "$REMOTE_COUNT" -eq 0 ]; then
  echo "ERROR: sentry-cli uploaded zero .map files — check --url-prefix and --dist-dir"
  exit 1
fi

if [ "$LOCAL_COUNT" != "$REMOTE_COUNT" ]; then
  echo "ERROR: count mismatch — $LOCAL_COUNT local chunks, $REMOTE_COUNT remote maps"
  echo "Run: sentry-cli releases files $RELEASE list"
  exit 1
fi

echo "OK: $REMOTE_COUNT/$LOCAL_COUNT maps verified in Sentry"

3. Add a local map-presence check before uploading

Catch missing maps at build time, before making any network calls:

#!/usr/bin/env bash
# scripts/assert-maps-exist.sh
set -euo pipefail

FAIL=0
while IFS= read -r -d '' JS_FILE; do
  MAP_FILE="${JS_FILE}.map"
  if [ ! -f "$MAP_FILE" ]; then
    echo "MISSING MAP: $MAP_FILE (for chunk $JS_FILE)"
    FAIL=1
  fi
done < <(find ./dist -name '*.js' ! -name '*.map' -type f -print0)

if [ "$FAIL" -eq 1 ]; then
  echo "ERROR: One or more JS chunks are missing their .map files."
  echo "Check that devtool: 'hidden-source-map' is set in your bundler config."
  exit 1
fi

echo "OK: every JS chunk has a corresponding .map file"

This check runs entirely locally — no Sentry API call needed — so it fails fast on bundler misconfiguration before any network latency.

4. Wire all three checks into the GitHub Actions workflow

# .github/workflows/deploy.yml (validation section)
      - name: Count local JS chunks
        run: bash scripts/count-chunks.sh

      - name: Assert every chunk has a local .map file
        run: bash scripts/assert-maps-exist.sh

      - name: Upload source maps and verify count in Sentry
        run: bash scripts/upload-and-verify.sh
        env:
          SENTRY_RELEASE: ${{ github.sha }}
          SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
          SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
          SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
          LOCAL_JS_CHUNKS: ${{ env.LOCAL_JS_CHUNKS }}

      # Only reached if all three checks pass
      - name: Sync assets to CDN (maps excluded)
        run: |
          aws s3 sync ./dist s3://${{ secrets.CDN_BUCKET }}/static/ \
            --exclude "*.map"

Each step runs only if the previous one exits 0. A mismatch at any point halts the pipeline before CDN deployment, ensuring no traffic ever shifts to a bundle whose maps are missing from Sentry. The ordering matters: the two local checks run first because they need no network access and fail in milliseconds, so a broken bundler config never wastes an upload round trip.

CI Source Map Validation PipelineFour sequential CI steps: count local chunks, assert every chunk has a local map, upload and verify the count in Sentry, and only then sync assets to the CDN.Count chunksAssert mapsUpload + verifyCDN sync

5. Add an optional strict per-chunk URL probe

After the upload, verify each map is reachable via the Sentry artifact API (not the CDN — they must be private):

#!/usr/bin/env bash
# scripts/probe-artifact-urls.sh
set -euo pipefail

RELEASE="$SENTRY_RELEASE"
TOKEN="$SENTRY_AUTH_TOKEN"
ORG="$SENTRY_ORG"
PROJECT="$SENTRY_PROJECT"

# Fetch the artifact list and check each .map artifact has a non-zero size
curl -sf \
  -H "Authorization: Bearer $TOKEN" \
  "https://sentry.io/api/0/projects/$ORG/$PROJECT/releases/$RELEASE/files/?per_page=100" \
  | python3 -c "
import sys, json
files = json.load(sys.stdin)
errors = []
for f in files:
    if f['name'].endswith('.map') and f.get('size', 0) == 0:
        errors.append(f['name'])
if errors:
    print('Zero-byte map artifacts:', errors)
    sys.exit(1)
print(f'All {len([f for f in files if f[\"name\"].endswith(\".map\")])} maps are non-zero.')
"

Zero-byte uploads happen when the upload command finds the file in the directory but cannot read it due to a permissions issue in the CI runner’s temp directory.

Verification

Run the scripts locally against a completed build output to confirm they work before committing:

# Simulate a build with a missing map
mkdir -p ./dist
touch ./dist/app.4f9a1b2c.js         # no corresponding .map
bash scripts/assert-maps-exist.sh
# Expected output:
# MISSING MAP: ./dist/app.4f9a1b2c.js.map (for chunk ./dist/app.4f9a1b2c.js)
# ERROR: One or more JS chunks are missing their .map files.
# Exit code: 1

# Simulate a correct build
touch ./dist/app.4f9a1b2c.js.map
bash scripts/assert-maps-exist.sh
# Expected output:
# OK: every JS chunk has a corresponding .map file
# Exit code: 0

Edge Cases & Gotchas

  • Lazy-loaded chunks in dynamic importsfind ./dist -name '*.js' will include split chunks from import() calls. Ensure your Webpack or Vite config generates .map files for every chunk, not just the entry point. Set optimization.splitChunks with a matching devtool setting.
  • Service worker files — if your build outputs sw.js, it needs its own .map too. The local presence check will catch it, but some teams explicitly exclude the service worker from the count: add ! -name 'sw.js' to the find command only if you have a deliberate reason not to symbolicate the service worker.
  • Webpack stats JSON as an alternative to find — Webpack emits stats.json when stats: { all: true } is set. Parsing stats.json for assets[].name is more reliable than filesystem globbing because it reflects exactly what Webpack considers part of the build, including assets emitted by plugins.
  • Sentry API rate limits — the files list endpoint is rate-limited at 100 per page. If you have more than 100 artifacts per release (unusual), add pagination: &cursor= support to the probe script.
  • Debug-ID uploads skew the count — when you switch to sentry-cli sourcemaps inject, artifacts are keyed by debugId and may be deduplicated across releases, so a strict N = M equality can report a false mismatch on a rebuild that reuses an already-uploaded map. In that mode, assert M >= 1 and that every local chunk carries a debugId comment rather than counting for exact parity.
  • Matrix builds double the local count — if your workflow builds several targets in a matrix and writes them all into one ./dist, find counts every target’s chunks while the upload may only send one target’s maps. Namespace each target into its own output directory, or scope the find to the directory you actually upload.

FAQ

Why does sentry-cli sourcemaps upload exit 0 when it uploads nothing? The sentry-cli design treats a zero-file upload as a no-op rather than an error because the command is meant to be idempotent — uploading the same artifact twice should not break a pipeline. The responsibility for asserting a minimum upload count falls to you. The scripts in this page implement that check explicitly by querying the releases API immediately after the upload.

Can I use sentry-cli sourcemaps inject instead of upload, and does the same validation apply? sourcemaps inject adds a debugId annotation to each JS file and its corresponding .map, then sourcemaps upload sends both to Sentry keyed by debug ID instead of release + URL. The count-match validation still applies — you still need to assert that every injected JS chunk has a corresponding annotated .map uploaded. The per-chunk local file check (assert-maps-exist.sh) works unchanged.

What if the Sentry API returns a 401 during the post-upload verification query? A 401 means the SENTRY_AUTH_TOKEN secret either has insufficient scope or has been rotated since the CI secret was last updated. The token needs project:releases scope. Verify in Sentry under Settings → Auth Tokens. Update the repository secret and re-run the failed job.