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 Logic A flow diagram showing: Build Output feeds into Count Local JS Chunks (N), which connects to Count Remote Maps (M). A decision diamond checks N equals M. Yes path leads to green PASS box. No path leads to red FAIL / exit 1 box. Build Output ./dist/*.js + *.map Count Local JS find ./dist -name '*.js' → N Count Remote Maps Sentry API → M N = M? PASS FAIL — exit 1 blocks CDN deploy yes no

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.

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.

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.

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.