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.
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
maintrigger parallel workflow runs, both will callsentry-cli releases newwith 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-cliversion skew — pinning@sentry/cliinpackage.jsonprevents 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-clibinary in a private artifact store and reference it withSENTRY_CLI_EXECUTABLEenvironment 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 usesSENTRY_PROJECTto 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.
Related
- CI/CD Source Map Upload and Validation — parent cluster covering the full pipeline
- Source Map Generation & Stack Trace Debugging — grandparent pillar
- Failing the Build When Source Maps Are Missing — validation gate that runs after this upload step
- Injecting Synthetic Errors in Staging to Verify Symbolication — end-to-end confirmation after upload
- Configuring Webpack for Production Source Maps — bundler configuration prerequisite