CI/CD Source Map Upload and Validation

A production error tracker that cannot symbolicate minified frames is worse than useless — it floods engineers with noise that looks actionable but is not. The fix is a deterministic CI/CD pipeline that treats source maps as first-class release artifacts: built with content-addressed names, uploaded to your error tracker before traffic shifts, validated for completeness, stripped from the public CDN, and tagged with the exact commit SHA that produced them. This guide walks through that full pipeline from npm run build to a verified release in Source Map Generation & Stack Trace Debugging. Teams working on the bundler side should first read Configuring Webpack for Production Source Maps for the devtool: 'hidden-source-map' prerequisite; teams managing observability routing should cross-reference Integrating Observability SDKs — Sentry, Datadog, OpenTelemetry.

Concrete outcomes from this pipeline:

  • Every minified chunk in your CDN has a corresponding .map artifact in Sentry/Datadog/custom storage.
  • The CI job fails — not warns — when any .map is missing or the upload count mismatches chunk count.
  • Public URLs never serve .map files; a 403 or 404 is intentional and verified.
  • Each Sentry release is tagged with GITHUB_SHA so stack traces resolve to the exact source revision.
  • A synthetic throw in staging confirms end-to-end symbolication before the real rollout completes.
CI/CD Source Map Pipeline Five sequential stages: Build Artifacts, Upload to Tracker, Validate Presence, Delete Public Maps, Correlate Release. Arrows connect each stage left to right. A validation gate beneath the third stage shows the failure path. Build hidden-source-map Upload Maps sentry-cli / API Validate count match + 200 Delete Public .map off CDN Tag Release commit SHA FAIL BUILD exit 1 mismatch ✓ count match → proceed Synthetic throw in staging verifies symbolication end-to-end

Problem Framing & Symptom Identification

Symbolication breaks silently. A Sentry issue shows a minified frame like at t (main.4f9a.js:1:38291) and no engineer raises a ticket because the tracker accepted the upload without complaint. The actual failure happened earlier: the .map file was either never uploaded, uploaded with a mismatched URL, or overwritten by a duplicate release tag that pointed at last week’s artifacts.

There are four distinct failure classes:

  1. Missing upload — the CI job ran npm run build but skipped the upload step on a branch that merges directly to main.
  2. URL mismatch — the map was uploaded under https://cdn.example.com/static/main.js.map but the bundle’s //# sourceMappingURL= points to https://cdn.example.com/v2/static/main.js.map after a CDN path restructure.
  3. Stale release tag — two deploys reuse the same SENTRY_RELEASE value because CI_COMMIT_SHA was not wired in, causing Sentry to use the first upload’s maps for the second deploy’s errors.
  4. Public map exposure — the S3 or CloudFront sync pushed .map files to the CDN before the delete step ran, briefly exposing source.

Each failure requires a different fix, but all are caught by the same three-stage validation gate: count check, URL probe, and symbolication smoke test.

Prerequisites & Environment Setup

# Install sentry-cli globally in the CI image or as a dev dependency
npm install --save-dev @sentry/cli

# Verify sentry-cli is on PATH
npx sentry-cli --version
# sentry-cli 2.x.x

# Required environment variables (set as CI secrets, not in committed yaml):
# SENTRY_AUTH_TOKEN  — scoped to project:releases and org:read
# SENTRY_ORG        — your Sentry organisation slug
# SENTRY_PROJECT    — your Sentry project slug
# SENTRY_RELEASE    — derived from commit SHA at runtime (see pipeline below)

Your Webpack or Vite build must already produce separate .map files that are NOT referenced by a //# sourceMappingURL= comment in the bundle (i.e. devtool: 'hidden-source-map' in Webpack). If your bundles still carry that comment, consult Configuring Webpack for Production Source Maps before wiring the upload step.

The CI runner needs write access to the Sentry releases API. For GitHub Actions, store SENTRY_AUTH_TOKEN as a repository secret and pass it explicitly to the job step — do not rely on ambient environment inheritance.

Step-by-Step Implementation

1. Derive the release identifier from the commit SHA

# .github/workflows/deploy.yml (partial)
env:
  # GITHUB_SHA is always available in GitHub Actions
  SENTRY_RELEASE: ${{ github.sha }}
# In a self-hosted runner or local test, derive the same value:
SENTRY_RELEASE=$(git rev-parse HEAD)
export SENTRY_RELEASE

Using the full 40-character SHA avoids the collision risk of short hashes and guarantees that every release tag is globally unique across parallel branch deploys. Never use a build number, timestamp, or semantic-version string as the release tag — they are not deterministic across retries.

2. Build with content-addressed output filenames

// webpack.config.js
module.exports = {
  mode: 'production',
  devtool: 'hidden-source-map',              // maps generated, no bundle comment
  output: {
    filename: '[name].[contenthash:8].js',   // content hash keeps names deterministic
    chunkFilename: '[name].[contenthash:8].chunk.js',
    path: path.resolve(__dirname, 'dist'),
    publicPath: 'https://cdn.example.com/static/',
  },
};

The contenthash ensures that a chunk that did not change between two commits gets the same filename. This matters for the validation step: if chunk A is app.4f9a1b2c.js, its map must be app.4f9a1b2c.js.map. A mismatch here is the most common root cause of “uploaded but unsymbolicated” issues.

3. Create the Sentry release and inject commit context

# Create the release; associate commits for blame information
npx sentry-cli releases new "$SENTRY_RELEASE"

npx sentry-cli releases set-commits "$SENTRY_RELEASE" \
  --auto                                    # reads git log from the working directory

The --auto flag reads the local .git history and associates all commits since the previous release. This populates Sentry’s “Commits & PRs” panel and enables suspect commit highlighting on grouped issues.

4. Upload source maps under the correct URL prefix

# Upload every .map file, rewriting the URL to match the bundle's publicPath
npx sentry-cli sourcemaps upload \
  --release "$SENTRY_RELEASE" \
  --url-prefix "https://cdn.example.com/static/" \
  ./dist

The --url-prefix must exactly match output.publicPath in your Webpack config (or base + assetsDir in Vite). A trailing slash matters. Sentry computes the artifact key by concatenating this prefix with the filename; a mismatch means the lookup fails at symbolication time even though the artifact exists in storage.

5. Validate upload count matches chunk count

#!/usr/bin/env bash
# scripts/validate-sourcemaps.sh

set -euo pipefail

RELEASE="$1"                                 # pass SENTRY_RELEASE as first arg
ORG="${SENTRY_ORG}"
PROJECT="${SENTRY_PROJECT}"
TOKEN="${SENTRY_AUTH_TOKEN}"

# Count .js chunks produced by the build
LOCAL_COUNT=$(find ./dist -name '*.js' ! -name '*.map' | wc -l | tr -d ' ')

# Count artifacts uploaded to Sentry for this release
REMOTE_COUNT=$(curl -s \
  -H "Authorization: Bearer $TOKEN" \
  "https://sentry.io/api/0/projects/$ORG/$PROJECT/releases/$RELEASE/files/?per_page=100" \
  | python3 -c "import sys,json; d=json.load(sys.stdin); print(len([f for f in d if f['name'].endswith('.map')]))")

echo "Local JS chunks : $LOCAL_COUNT"
echo "Remote .map files: $REMOTE_COUNT"

if [ "$LOCAL_COUNT" != "$REMOTE_COUNT" ]; then
  echo "ERROR: upload count mismatch — $LOCAL_COUNT chunks, $REMOTE_COUNT maps uploaded"
  exit 1                                     # fails the CI job
fi
echo "OK: all $LOCAL_COUNT source maps verified"

Run this script after the upload step and before CDN sync. A non-zero exit code is the gate that blocks the deploy.

6. Sync the bundle to the CDN and delete public map files

# Sync built assets to S3 (adjust bucket/profile for your CDN provider)
aws s3 sync ./dist s3://cdn-bucket/static/ \
  --exclude "*.map"                          # never push .map to the public bucket

# If maps were previously leaked, remove them explicitly:
aws s3 rm s3://cdn-bucket/static/ \
  --recursive \
  --exclude "*" \
  --include "*.map"

Using --exclude "*.map" in the sync command is simpler than a post-delete step because it never writes the files to begin with. A separate delete pass is a defensive backstop for legacy pipelines where sync ran without the exclusion.

7. Finalize the Sentry release and correlate the deploy

# Mark the release as deployed to the target environment
npx sentry-cli releases deploys "$SENTRY_RELEASE" new \
  --env production

# Finalize (sets dateReleased so the release appears in dashboards)
npx sentry-cli releases finalize "$SENTRY_RELEASE"

Finalizing without deploying leaves the release in a “pending” state that Sentry’s regression detection ignores. Always call deploys new before finalize.

Release Strategy & Versioning

Every step in the pipeline references a release identifier. Getting that identifier wrong is the single most common cause of symbolication failure, so it is worth understanding exactly how the chain must be consistent.

Sentry treats a “release” as an immutable record keyed by a string. When an event arrives, Sentry reads the release field from the SDK payload and looks up the artifact set stored under that key. If no artifacts exist for that key, symbolication fails immediately without retry. The identifier must therefore be:

  1. Unique per deploy — two different commits must never share a release tag. Using git rev-parse HEAD (the full 40-character SHA) guarantees this. Semantic version strings (v1.2.3) break as soon as you build a hotfix or re-deploy the same tag.
  2. Identical across three systems — the Webpack DefinePlugin (baked into the bundle), the sentry-cli sourcemaps upload --release argument, and the Sentry.init({ release }) call in the SDK must all receive the same string. A mismatch between any two of these three is sufficient to break symbolication.
  3. Short enough for the API — Sentry’s release tag limit is 200 characters. A full SHA is 40 characters; there is no reason to concatenate branch names, timestamps, or semver on top of it.

For monorepos with multiple independent deployable units (a web app, a marketing site, a worker script), each unit should derive its release from its own commit SHA and a namespace prefix:

# For a monorepo: prefix the SHA with the package name to namespace releases
SENTRY_RELEASE="web-app@$(git rev-parse HEAD)"
SENTRY_RELEASE="worker@$(git rev-parse HEAD)"

Each prefix maps to a separate Sentry project. Pass --dist to sentry-cli (e.g. --dist web) to disambiguate artifact sets within a shared project if you cannot create separate Sentry projects.

Handling rollbacks

When a deploy is rolled back, the CDN reverts to an older bundle. Sentry will start receiving events tagged with the old release SHA. Because you never deleted the old Sentry release (only its public CDN maps), symbolication works automatically for rolled-back events — the artifacts are still present in Sentry storage. No reprocessing is necessary unless the old release was finalized under a different SHA than the one the SDK baked in.

# After rollback: confirm the old release still has artifacts
npx sentry-cli releases files "$OLD_SENTRY_RELEASE" list
# Expected: same artifact list as before the rollback

Using Sentry debug IDs instead of release-based upload

Sentry 2.x introduced debug IDs as an alternative to the release + URL-prefix lookup. sentry-cli sourcemaps inject annotates each JS file with a unique debugId comment and stores the same ID in the map’s sources field. At symbolication time, Sentry matches by debug ID rather than URL, which eliminates the --url-prefix configuration entirely:

# Inject debug IDs into built files (modifies ./dist in-place)
npx sentry-cli sourcemaps inject ./dist

# Upload using debug IDs (no --url-prefix needed)
npx sentry-cli sourcemaps upload --release "$SENTRY_RELEASE" ./dist

The tradeoff: inject modifies the bundle files in ./dist before CDN sync, which changes their content hashes. If your CDN caching is keyed to content hash, inject must run before the CDN sync step. Plan the step order accordingly.

Production Telemetry Integration

The Sentry SDK must know the same release identifier that the CI pipeline used. Pass it during SDK initialization so every captured event carries the release tag:

// src/instrumentation.js  (loaded before any application code)
import * as Sentry from '@sentry/browser';

Sentry.init({
  dsn: process.env.SENTRY_DSN,
  release: process.env.SENTRY_RELEASE,       // injected by the build tool (see below)
  environment: process.env.NODE_ENV,
  tracesSampleRate: 0.1,
});

Inject SENTRY_RELEASE at build time using Webpack DefinePlugin or Vite’s define:

// webpack.config.js (additions)
const { DefinePlugin } = require('webpack');

module.exports = {
  // …existing config…
  plugins: [
    new DefinePlugin({
      // Reads the env var set by the CI pipeline
      'process.env.SENTRY_RELEASE': JSON.stringify(process.env.SENTRY_RELEASE || 'dev'),
    }),
  ],
};

When an error fires in the browser, Sentry matches the event’s release field against the uploaded artifact set keyed to the same SHA. If the SDK and the sentry-cli upload use different strings, symbolication silently falls back to raw frames. The observability integration pattern is covered in depth in Integrating Observability SDKs — Sentry, Datadog, OpenTelemetry.

Verification & Testing

After the full pipeline runs, perform three checks:

Check 1 — Artifact count via the API

curl -s \
  -H "Authorization: Bearer $SENTRY_AUTH_TOKEN" \
  "https://sentry.io/api/0/projects/$SENTRY_ORG/$SENTRY_PROJECT/releases/$SENTRY_RELEASE/files/" \
  | python3 -m json.tool | grep '"name"'
# Expect one entry per .js chunk and one per .map file

Check 2 — Public map is inaccessible

HTTP_STATUS=$(curl -o /dev/null -s -w "%{http_code}" \
  "https://cdn.example.com/static/app.4f9a1b2c.js.map")
[ "$HTTP_STATUS" = "403" ] || [ "$HTTP_STATUS" = "404" ] \
  && echo "OK: map not public" \
  || { echo "FAIL: map is public ($HTTP_STATUS)"; exit 1; }

Check 3 — Symbolicated error in Sentry

Trigger the synthetic error described in Injecting Synthetic Errors in Staging to Verify Symbolication and assert that the Sentry issue shows original source paths rather than minified identifiers.

Failure Modes & Edge Cases

Scenario Root Cause Fix
Sentry shows minified frames despite upload success --url-prefix does not match output.publicPath exactly Run sentry-cli sourcemaps explain <event-id> to see the URL lookup that failed
Upload count validation passes but one file is stale Content hash collision across two commits (extremely rare, SHA-256) Verify with sentry-cli releases files $RELEASE list and re-upload if artifact date predates the build
sentry-cli releases new fails with 400 Release tag exceeds 200 characters or contains spaces Truncate SHA to 40 chars; never append branch names
.map files appear on the CDN intermittently CDN edge cache served a stale response after the delete step Invalidate the CDN path with aws cloudfront create-invalidation --paths '/static/*.map'
Maps uploaded but Sentry still shows <anonymous> The bundle uses eval-based devtool (eval-source-map) which cannot be uploaded Switch to hidden-source-map or source-map devtool before the upload pipeline
Parallel branch deploys overwrite each other’s artifacts Both branches use the same SENTRY_RELEASE value Always derive release from full git rev-parse HEAD, never from branch name

FAQ

Why does sentry-cli sourcemaps upload succeed but Sentry still shows main.js:1:38291? The most frequent cause is a URL mismatch. Sentry constructs the artifact lookup key as url-prefix + filename. If --url-prefix has a different protocol, subdomain, or path segment than the publicPath your Webpack build used, the lookup returns no result and Sentry renders the raw minified frame. Run sentry-cli sourcemaps explain <event-id> — it prints the exact URLs it attempted and which step failed.

Should I upload source maps to Sentry before or after pushing assets to the CDN? Always upload to Sentry first. If the CDN sync runs before the upload and a real user error fires in the seconds between the two operations, Sentry will attempt symbolication against an empty artifact set and cache the failure. Sentry does not automatically retry symbolication for already-processed events; you must reprocess the issue manually.

Can I upload source maps for a monorepo with multiple build outputs? Yes. Run sentry-cli sourcemaps upload once per package, with the correct --url-prefix for each output. If all packages share a single Sentry project, include a --dist flag (e.g. --dist web vs --dist worker) to disambiguate artifact sets within the same release. The dist value must also be passed to Sentry.init({ dist: '...' }) in the corresponding SDK initialization.

What happens if I forget to call sentry-cli releases finalize? The release stays in a draft state. It will not appear in Sentry’s release health dashboard, regression detection will not fire against it, and the “adoption” metric will not accumulate. Finalize immediately after the deploy step; do not defer it to a post-deployment hook that might not run on pipeline failure.