diff --git a/CHANGELOG.md b/CHANGELOG.md
index 785d770..74f1334 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -19,6 +19,20 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
carrying an unresolvable debug-id `missing_sym`, which is what prompts an upload — an unstamped
bundle is silently unsymbolicated instead.
+### Fixed
+- **`debug-files upload --type sourcemaps --dry-run` no longer fails on a map that has no
+ debug-id.** A dry run sends nothing, so an un-keyed map cannot register the unfindable symbol the
+ real run refuses over — but it exited 11 on the first one, which made the documented SAFE
+ diagnostic unusable on a freshly built directory: `sourcemaps inject --dry-run` writes nothing by
+ design, so every map is still un-keyed when the preview reaches it. (`@bugsee/bundler-plugin-core`
+ works around this by skipping the upload step entirely on a dry run, so the one safe way to
+ preview the flow never exercised the flow.)
+
+ Such a map is now reported (`dry run: no debug_id — …`) and counted (`unkeyed` in the completion
+ log) instead. A build where NOTHING is keyed completes as a success saying so, rather than falling
+ into the "all 0 source maps are stylesheet maps" branch, which was plainly wrong there. A real run
+ still exits 11 on the first un-keyed map, and `--uuid` still keys a map on a dry run as usual.
+
## [0.7.10] - 2026-09-18
### Added
diff --git a/README.md b/README.md
index 4614b6e..6959e80 100644
--- a/README.md
+++ b/README.md
@@ -216,6 +216,11 @@ sequential uploads whatever the ceiling says — those registrations must not ra
each other. A failed upload stops the batch rather than letting the rest run
into a server that has already refused one.
+A `--dry-run` discovers and packs but sends nothing, so a map that carries no debug-id is reported
+rather than fatal there (`unkeyed` in the completion log) — the whole flow can be previewed on a
+freshly built directory, where `sourcemaps inject --dry-run` has deliberately written nothing yet.
+A REAL run still refuses such a map (exit 11): uploading it would register a symbol nothing can find.
+
`--allow-empty` turns "nothing to upload" into success (exit 0) instead of
exit 10 — a monorepo package built without maps, or a framework whose server
output has none, is a legitimate no-op rather than a reason to fail the build.
diff --git a/scripts/e2e_flows.py b/scripts/e2e_flows.py
index 16b3e28..834ec54 100644
--- a/scripts/e2e_flows.py
+++ b/scripts/e2e_flows.py
@@ -398,6 +398,17 @@ def main():
results["sourcemaps_uninjected_uploads_nothing"] = not os.path.exists(
cappath("sourcemaps_uninjected__symbols_posts.jsonl"))
+ # The same directory under --dry-run is the documented SAFE diagnostic, and it used to die on
+ # the first un-keyed map (exit 11) — which is why the bundler plugin skips the upload step
+ # entirely on a dry run. It now succeeds and names what `sourcemaps inject` would key.
+ results["sourcemaps_uninjected_dry_run_succeeds"] = run(
+ binpath, "sourcemaps_uninjected_dry",
+ ["debug-files", "upload", "--type", "sourcemaps", "--dry-run",
+ os.path.join(fix, "web-uninjected")] + v,
+ expect_stderr="no debug_id")
+ results["sourcemaps_dry_run_uploads_nothing"] = not os.path.exists(
+ cappath("sourcemaps_uninjected_dry__symbols_posts.jsonl"))
+
# --concurrency uploads several maps at once: every one of them must still be registered
# exactly once. (The unit tests prove the overlap itself; this proves nothing is dropped.)
results["sourcemaps_concurrent_upload"] = run(
diff --git a/src/cli/debug_files.rs b/src/cli/debug_files.rs
index 42d1b71..a2eeae3 100644
--- a/src/cli/debug_files.rs
+++ b/src/cli/debug_files.rs
@@ -1545,6 +1545,8 @@ async fn run_sourcemap_upload(
// Pass 1: identify and key every map, uploading nothing.
let mut planned = Vec::with_capacity(candidates.len());
let mut skipped = 0usize;
+ // Maps a DRY RUN found with no debug-id. Always 0 on a real run: that returns on the first one.
+ let mut unkeyed = 0usize;
for SourcemapCandidate {
path: map_path,
explicit,
@@ -1580,18 +1582,34 @@ async fn run_sourcemap_upload(
}
supplied_str
}
- None => identity.debug_id.clone().ok_or_else(|| {
- input_invalid(format!(
- "source map has no debug_id/debugId/uuid: {} — nothing was uploaded. Run \
- `bugsee-cli sourcemaps inject
` over the directory holding its JS \
- bundle first: inject stamps a `.js`/`.cjs`/`.mjs` bundle's map when it sits \
- beside the bundle as `.map`, or when the bundle's \
- `//# sourceMappingURL=` names it by a relative path inside the bundle's \
- directory. For a single map whose id you own (e.g. a React Native \
- bundle's), pass that file with --uuid",
- map_path.display()
- ))
- })?,
+ None => match identity.debug_id.clone() {
+ Some(id) => id,
+ // A dry run sends nothing, so an un-keyed map cannot produce the unfindable symbol
+ // the real run refuses over. Failing here made the documented SAFE diagnostic
+ // unusable on a freshly built directory: `sourcemaps inject --dry-run` writes
+ // nothing by design, so every map is still un-keyed and the preview died on the
+ // first one. Reported and counted instead.
+ None if dry_run => {
+ unkeyed += 1;
+ tracing::warn!(
+ path = %map_path.display(),
+ "dry run: no debug_id — `sourcemaps inject` keys it before a real upload"
+ );
+ continue;
+ }
+ None => {
+ return Err(input_invalid(format!(
+ "source map has no debug_id/debugId/uuid: {} — nothing was uploaded. Run \
+ `bugsee-cli sourcemaps inject ` over the directory holding its JS \
+ bundle first: inject stamps a `.js`/`.cjs`/`.mjs` bundle's map when it \
+ sits beside the bundle as `.map`, or when the bundle's \
+ `//# sourceMappingURL=` names it by a relative path inside the bundle's \
+ directory. For a single map whose id you own (e.g. a React Native \
+ bundle's), pass that file with --uuid",
+ map_path.display()
+ )));
+ }
+ },
};
tracing::info!(
debug_id = %resolved_id,
@@ -1603,6 +1621,17 @@ async fn run_sourcemap_upload(
}
if planned.is_empty() {
+ // A dry run over a freshly built directory: every map is un-keyed because `inject --dry-run`
+ // wrote nothing. That is the preview working, not a failure — and the stylesheet message
+ // below would be plainly wrong ("all 0 source maps are stylesheet maps").
+ if dry_run && unkeyed > 0 {
+ tracing::info!(
+ unkeyed,
+ skipped,
+ "dry-run complete: no map carries a debug_id yet — `sourcemaps inject` keys them"
+ );
+ return Ok(());
+ }
if allow_empty {
tracing::info!(
skipped,
@@ -1684,7 +1713,7 @@ async fn run_sourcemap_upload(
}
if dry_run {
- tracing::info!(skipped, "dry-run complete");
+ tracing::info!(skipped, unkeyed, "dry-run complete");
} else {
tracing::info!(uploaded, already_existed, skipped, "upload complete");
}
@@ -2894,6 +2923,107 @@ mod sourcemap_upload_tests {
assert_eq!(puts(&server).await, 1);
}
+ /// `--dry-run` is documented as the safe diagnostic, and it could not be used on a freshly built
+ /// output directory at all: `sourcemaps inject --dry-run` writes nothing by design, so the maps
+ /// still carry no debug-id and the upload then failed with exit 11 on the FIRST one. The JS
+ /// bundler plugin works around it by skipping the upload step entirely on a dry run, which means
+ /// the one safe way to preview the flow never exercises the flow.
+ ///
+ /// A dry run sends nothing, so an un-keyed map cannot produce an unfindable symbol — the reason
+ /// the real run refuses. It is now reported and counted instead.
+ #[tokio::test]
+ async fn a_dry_run_reports_maps_with_no_debug_id_instead_of_failing() {
+ let tmp = tempfile::tempdir().unwrap();
+ write(
+ tmp.path(),
+ "keyed.js.map",
+ br#"{"version":3,"debug_id":"11111111-1111-1111-1111-111111111111","mappings":""}"#,
+ );
+ write(tmp.path(), "bare.js.map", br#"{"version":3,"mappings":""}"#);
+ let server = collector(&[]).await;
+
+ // The real run still refuses: uploading an un-keyed map registers a symbol nothing can find.
+ let err = upload_paths(
+ &[tmp.path().to_path_buf()],
+ &server.uri(),
+ SourcemapUploadTweak::default(),
+ )
+ .await
+ .unwrap_err();
+ assert_eq!(
+ crate::error::classify(&err),
+ crate::exit_code::ExitCode::InputInvalid
+ );
+ assert!(
+ posted_ids(&server).await.is_empty(),
+ "nothing is registered"
+ );
+
+ // The dry run succeeds, and still packs the map that IS keyed.
+ upload_paths(
+ &[tmp.path().to_path_buf()],
+ &server.uri(),
+ SourcemapUploadTweak {
+ dry_run: true,
+ ..SourcemapUploadTweak::default()
+ },
+ )
+ .await
+ .unwrap();
+ assert!(
+ posted_ids(&server).await.is_empty(),
+ "a dry run sends nothing"
+ );
+ }
+
+ /// …and when NOTHING is keyed, the dry run is still a success: there is nothing to warn about
+ /// twice, and failing here would put the workaround back.
+ #[tokio::test]
+ async fn a_dry_run_over_an_entirely_uninjected_build_still_succeeds() {
+ let tmp = tempfile::tempdir().unwrap();
+ for name in ["a.js.map", "b.js.map"] {
+ write(tmp.path(), name, br#"{"version":3,"mappings":""}"#);
+ }
+ let server = collector(&[]).await;
+
+ upload_paths(
+ &[tmp.path().to_path_buf()],
+ &server.uri(),
+ SourcemapUploadTweak {
+ dry_run: true,
+ ..SourcemapUploadTweak::default()
+ },
+ )
+ .await
+ .unwrap();
+ assert!(puts(&server).await == 0 && posted_ids(&server).await.is_empty());
+ }
+
+ /// An explicit `--uuid` still wins on a dry run — the override is what keys the map, so it is
+ /// not "missing" at all.
+ #[tokio::test]
+ async fn a_dry_run_with_a_uuid_override_keys_the_map_as_usual() {
+ let tmp = tempfile::tempdir().unwrap();
+ write(tmp.path(), "bare.js.map", br#"{"version":3,"mappings":""}"#);
+ let server = collector(&[]).await;
+
+ run_sourcemap_upload(
+ &[tmp.path().join("bare.js.map")],
+ &server.uri(),
+ "TKN",
+ "1.0",
+ "1",
+ Some(Uuid::parse_str("22222222-2222-2222-2222-222222222222").unwrap()),
+ Strategy::Zstd(11),
+ false,
+ None,
+ false,
+ true,
+ )
+ .await
+ .unwrap();
+ }
+
/// A build step that legitimately produces no maps — a monorepo package built without them, a
/// framework whose server output has none — must not fail the caller's build. Opt-in, because a
/// scan that finds nothing is a configuration mistake as often as it is a legitimate no-op.