Uploading Source Maps from GitHub Actions to Sentry

This page covers the exact GitHub Actions YAML and sentry-cli invocations needed to upload source maps on every production deploy, keyed to the commit SHA. It is a concrete implementation of the upload stage described in CI/CD Source Map Upload and Validation, which sits under the Source Map Generation & Stack Trace Debugging pillar.

GitHub Actions Source Map Upload Workflow Six sequential job steps shown as boxes connected by arrows: Checkout Code, npm build, sentry-cli releases new, sentry-cli sourcemaps upload, Deploy to CDN (exclude .map), sentry-cli releases finalize. Checkout actions/checkout Build npm run build New Release sentry-cli Upload Maps sentry-cli Deploy CDN exclude *.map Finalize releases finalize SENTRY_RELEASE = github.sha (40-char SHA) ✓ Symbolicated frames in Sentry after deploy

Symptom / Trigger

The pipeline uploads nothing, or uploads to the wrong release, producing events in Sentry that display raw minified frames:

Error: Cannot read properties of undefined (reading 'id')
    at t (https://cdn.example.com/static/main.4f9a1b2c.js:1:38291)
    at e (https://cdn.example.com/static/main.4f9a1b2c.js:1:12043)

Sentry’s artifact panel for the release shows zero files, or lists files whose URL prefix does not match the bundle URL, confirming no successful upload occurred.

Root Cause Explanation

The most common broken pattern is running the upload as a separate, manually triggered workflow that lacks access to the same GITHUB_SHA the build used, or running it after CDN deployment has already promoted traffic to the new bundle:

# BROKEN: upload step runs after traffic is live, and uses a hardcoded version
- name: Upload source maps
  run: |
    npx sentry-cli sourcemaps upload \
      --release "v1.2.3" \          # static version string — breaks on every deploy
      --url-prefix "~/static/" \    # ~ is a Sentry shorthand — valid, but only if DSN matches
      ./dist

Two problems: the hardcoded release tag means Sentry associates every event with the same release regardless of commit, and ~/static/ expands to a URL that may not match your CDN’s actual publicPath.

Step-by-Step Fix

1. Store required secrets in GitHub

Navigate to Settings → Secrets and variables → Actions and add:

SENTRY_AUTH_TOKEN   — token with project:releases and org:read scopes
SENTRY_ORG          — your organisation slug (visible in Sentry URL)
SENTRY_PROJECT      — your project slug
SENTRY_DSN          — the project DSN for SDK initialization

Never commit these values to the repository. The workflow references them via ${{ secrets.* }}.

2. Write the complete workflow file

# .github/workflows/deploy.yml
name: Build, Upload Source Maps, Deploy

on:
  push:
    branches: [main]

permissions:
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    env:
      SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
      SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
      SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
      # Use the full 40-char SHA as the release identifier
      SENTRY_RELEASE: ${{ github.sha }}

    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          fetch-depth: 0           # full history needed for sentry-cli set-commits --auto

      - name: Setup Node
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Build
        run: npm run build
        env:
          # Inject the release tag into the bundle so the SDK can read it at runtime
          SENTRY_RELEASE: ${{ github.sha }}
          NODE_ENV: production

      - name: Create Sentry release and associate commits
        run: |
          npx sentry-cli releases new "$SENTRY_RELEASE"
          npx sentry-cli releases set-commits "$SENTRY_RELEASE" --auto

      - name: Upload source maps to Sentry
        run: |
          npx sentry-cli sourcemaps upload \
            --release "$SENTRY_RELEASE" \
            --url-prefix "https://cdn.example.com/static/" \
            ./dist
          # Verify at least one artifact was accepted
          COUNT=$(npx sentry-cli releases files "$SENTRY_RELEASE" list \
            --output json 2>/dev/null | python3 -c \
            "import sys,json; d=json.load(sys.stdin); print(len([f for f in d if f['name'].endswith('.map')]))")
          echo "Maps uploaded: $COUNT"
          [ "$COUNT" -gt 0 ] || { echo "ERROR: zero maps in Sentry"; exit 1; }

      - name: Sync assets to CDN (exclude source maps)
        run: |
          aws s3 sync ./dist s3://${{ secrets.CDN_BUCKET }}/static/ \
            --exclude "*.map"       # never publish .map files to the CDN
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          AWS_DEFAULT_REGION: us-east-1

      - name: Finalize Sentry release
        run: |
          npx sentry-cli releases deploys "$SENTRY_RELEASE" new --env production
          npx sentry-cli releases finalize "$SENTRY_RELEASE"

3. Inject the release tag into the Webpack build

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

module.exports = {
  mode: 'production',
  devtool: 'hidden-source-map',
  plugins: [
    new DefinePlugin({
      // process.env.SENTRY_RELEASE is set by the workflow env block
      'process.env.SENTRY_RELEASE': JSON.stringify(
        process.env.SENTRY_RELEASE || 'dev-local'
      ),
    }),
  ],
};

4. Initialize the Sentry SDK with the injected release

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

Sentry.init({
  dsn: process.env.SENTRY_DSN,
  release: process.env.SENTRY_RELEASE,  // matches the sentry-cli upload key
  environment: process.env.NODE_ENV,
});

If Sentry.init receives a different release value than the one used in sentry-cli sourcemaps upload, events and artifacts will never be matched and symbolication will fail.

5. Confirm the fetch-depth: 0 flag is present

- uses: actions/checkout@v4
  with:
    fetch-depth: 0    # default is 1 (shallow clone); sentry-cli needs full history

Without this, sentry-cli releases set-commits --auto fails to find the previous release and exits with an error that aborts the job. Shallow clones contain no ancestor commits for range calculation.

Verification

After the workflow completes, probe both the API and a live error:

# 1. List uploaded artifacts for the release
curl -s \
  -H "Authorization: Bearer $SENTRY_AUTH_TOKEN" \
  "https://sentry.io/api/0/projects/$SENTRY_ORG/$SENTRY_PROJECT/releases/$SENTRY_RELEASE/files/" \
  | python3 -c "import sys,json; [print(f['name']) for f in json.load(sys.stdin)]"
# Expected: one line per .js chunk and one per .js.map

# 2. Confirm the map is NOT accessible on the CDN
curl -I "https://cdn.example.com/static/main.4f9a1b2c.js.map"
# Expected: HTTP/2 403 or 404

# 3. Check Sentry issue for symbolicated frame (after triggering a known error)
# Sentry UI: Issues → select a recent event → Stack Trace
# Frame should show: src/components/App.tsx line 42, not main.4f9a.js:1:38291

Vite Variant

If your project uses Vite rather than Webpack, the build and inject steps differ. The core sentry-cli upload commands are identical.

// vite.config.js — generate hidden source maps (not served publicly)
import { defineConfig } from 'vite';

export default defineConfig({
  build: {
    sourcemap: 'hidden',         // generates .map files, omits bundle comment
    rollupOptions: {
      output: {
        entryFileNames: 'assets/[name].[hash].js',
        chunkFileNames: 'assets/[name].[hash].js',
        assetFileNames: 'assets/[name].[hash][extname]',
      },
    },
  },
  define: {
    // Inject the release tag at build time for Vite projects
    'import.meta.env.SENTRY_RELEASE': JSON.stringify(
      process.env.SENTRY_RELEASE || 'dev-local'
    ),
  },
});
// src/instrumentation.js — Vite projects read from import.meta.env
import * as Sentry from '@sentry/browser';

Sentry.init({
  dsn: import.meta.env.VITE_SENTRY_DSN,
  release: import.meta.env.SENTRY_RELEASE,   // must match sentry-cli --release value
  environment: import.meta.env.MODE,
});

The upload step in the workflow is unchanged — sentry-cli sourcemaps upload --release "$SENTRY_RELEASE" --url-prefix "https://cdn.example.com/assets/" ./dist/assets. Adjust --url-prefix to match Vite’s assetsDir output path, which is assets/ by default. See Vite Build Settings for Accurate Stack Traces for the full Vite configuration reference.

Debugging Reference

When the workflow succeeds but symbolication is broken, use this table to narrow the cause:

Symptom Diagnostic command Expected output If wrong
Sentry shows main.js:1:38291 sentry-cli sourcemaps explain <event-id> “Found artifact: …app.4f9a…js.map” Check --url-prefix matches publicPath
Zero artifacts in release sentry-cli releases files $RELEASE list One row per chunk and map Re-run upload step; check ./dist path
SDK release mismatch Inspect event JSON in Sentry UI → Tags → release Full 40-char SHA Check DefinePlugin / define bakes the SHA
CDN serves the .map curl -I https://cdn.example.com/…js.map HTTP/1.1 403 or 404 Re-run CDN sync with --exclude "*.map"
set-commits --auto fails git log --oneline -5 in runner Commit history visible Add fetch-depth: 0 to checkout step

Edge Cases & Gotchas

  • Concurrent deploys race condition — if two pushes to main trigger parallel workflow runs, both will call sentry-cli releases new with different SHAs, which is safe. The risk is that whichever CDN sync finishes last wins. Use a concurrency group in the workflow (concurrency: deploy-production) to serialize deploys.
  • sentry-cli version skew — pinning @sentry/cli in package.json prevents a breaking CLI update from silently changing flag names. Pin the minor version: "@sentry/cli": "~2.31.0".
  • Private npm registry — if your runner cannot reach the public npm registry, cache the sentry-cli binary in a private artifact store and reference it with SENTRY_CLI_EXECUTABLE environment variable pointing to the cached path.
  • Sentry DSN pointing at a different project than SENTRY_PROJECT — the SDK’s DSN routes events to one project; the upload uses SENTRY_PROJECT to route artifacts to another. Both must reference the same Sentry project or symbolication will never match.

FAQ

Does the --url-prefix need to be an absolute URL or can it be a relative path? Sentry accepts both. The ~/ shorthand expands to the scheme + host of your project’s DSN (e.g. https://your-app.example.com/). Use the full absolute URL when your assets are served from a CDN subdomain different from your app domain — the ~/ expansion will be wrong in that case. Always verify with sentry-cli sourcemaps explain <event-id> if symbolication fails after upload.

Can I upload source maps from a Docker-based CI runner that has no git history? Yes, but you must skip --auto and instead explicitly provide the previous release: sentry-cli releases set-commits "$SENTRY_RELEASE" --commit "myrepo@$SENTRY_RELEASE". Alternatively, pass --ignore-missing to suppress the error when no git history is available — commits will not be associated but maps will still upload correctly.

What if I need to re-upload maps for an already-finalized release? Call sentry-cli sourcemaps upload again with the same release tag. Sentry overwrites existing artifacts with matching names. You do not need to delete and recreate the release. If the release was already deployed and events processed against the old (missing) artifacts, open the affected issues and choose “Reprocess events” from the issue action menu.