diff --git a/.generated.NoMobile.slnx b/.generated.NoMobile.slnx
index bbf67bf776..7efa71bcc3 100644
--- a/.generated.NoMobile.slnx
+++ b/.generated.NoMobile.slnx
@@ -23,6 +23,7 @@
+
@@ -136,6 +137,7 @@
+
diff --git a/.github/workflows/changelog-guard.yml b/.github/workflows/changelog-guard.yml
new file mode 100644
index 0000000000..1793872b86
--- /dev/null
+++ b/.github/workflows/changelog-guard.yml
@@ -0,0 +1,19 @@
+name: Changelog Guard
+
+on:
+ pull_request:
+ branches:
+ - main
+ - release/**
+
+permissions:
+ contents: read
+
+jobs:
+ no-manual-unreleased-entries:
+ name: No manual "## Unreleased" entries
+ runs-on: ubuntu-24.04
+ steps:
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ - name: Verify CHANGELOG.md has no manual "## Unreleased" entries
+ run: ./scripts/verify-changelog.sh
diff --git a/AGENTS.md b/AGENTS.md
index bed5cae81d..381286b24b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,5 +1,17 @@
# Agent Instructions
+## Getting Started
+
+**Check out the submodules first.** The native SDKs and several build/tooling projects live in git submodules under `modules/` (e.g. `sentry-native`, `sentry-cocoa`, `perfview`). Without them, builds, solution-filter generation, and other tooling break in confusing ways — e.g. `scripts/generate-solution-filters.ps1` silently drops the missing projects from the `*.slnf` files.
+
+The bootstrap step handles this for you (it runs `git submodule update --init --recursive` plus a full restore):
+
+```sh
+./dev.cs cleanslate
+```
+
+If you only need to sync submodules (e.g. in a fresh worktree), run `./dev.cs subup` or `git submodule update --init --recursive` directly.
+
## Build System
Uses the .NET SDK (`dotnet` CLI). Key files:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 08dd24178d..2816f09939 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,11 +1,5 @@
# Changelog
-## Unreleased
-
-### Features ✨
-
-- feat: Add exponential backoff and log deduplication to Spotlight transport by @mattico in [#5025](https://github.com/getsentry/sentry-dotnet/pull/5025)
-
## 6.6.0
### Features ✨
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 008aca9983..ca959b2a07 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -109,28 +109,13 @@ To do that, run the build locally (i.e: `./build.sh` or `build.cmd`) and commit
## Changelog
We'd love for users to update the SDK everytime and as soon as we make a new release. But in reality most users rarely update the SDK.
-To help users see value in updating the SDK, we maintain a changelog file with entries split between two headings:
+To help users see value in updating the SDK, we maintain a changelog file with entries split between headings such as `### Features` and `### Fixes`.
-1. `### Features`
-2. `### Fixes`
+**Do not edit `CHANGELOG.md` manually.** The changelog is generated automatically at release time by [craft](https://github.com/getsentry/craft) (`changelogPolicy: auto` in `.craft.yml`) from the pull requests merged since the previous release. Entries are categorized from your [commit message / PR title](https://develop.sentry.dev/engineering-practices/commit-messages/) (e.g. a `feat:` PR becomes a Feature, a `fix:` PR becomes a Fix), so there's nothing to add by hand.
-We add the heading in the first PR that's adding either a feature or fixes in the current release.
-After a release, the [changelog file will contain only the last release entries](https://github.com/getsentry/sentry-dotnet/blob/main/CHANGELOG.md).
+If the PR title doesn't capture the change well — you want more detail, or a single PR should produce several entries — add a `### Changelog Entry` section to the PR description. craft uses the text under that heading verbatim instead of the PR title. See [Custom changelog entries from PR descriptions](https://craft.sentry.dev/configuration/#custom-changelog-entries-from-pr-descriptions).
-When you open a PR in such cases, you need to add a heading 2 named `## Unreleased`, which is replaced during release with the version number chosen.
-Below that, you'll add heading 3 mentioned above. For example, if you're adding a feature "Attach screenshots when capturing errors on WPF", right after a release, you'd add to the changelog:
-
-```
-## Unreleased
-
-### Features
-
-- Attach screenshots when capturing errors on WPF (#PR number)
-```
-
-There's a GitHub action check to verify if an entry was added.
-If the entry isn't a user-facing change, you can skip the verification with either `#skip-changelog` written to the PR description or the `skip-changelog` label added to the PR.
-The bot writes a comment in the PR with a suggestion entry to the changelog based on the PR title.
+If the change isn't user-facing and you'd rather it not appear in the changelog at all, add the `skip-changelog` label to the PR or write `#skip-changelog` in the PR description.
## Naming tests
diff --git a/Sentry.slnx b/Sentry.slnx
index bbf67bf776..7efa71bcc3 100644
--- a/Sentry.slnx
+++ b/Sentry.slnx
@@ -23,6 +23,7 @@
+
@@ -136,6 +137,7 @@
+
diff --git a/scripts/verify-changelog.sh b/scripts/verify-changelog.sh
new file mode 100755
index 0000000000..ff8b3b33ac
--- /dev/null
+++ b/scripts/verify-changelog.sh
@@ -0,0 +1,60 @@
+#!/usr/bin/env bash
+#
+# Verifies that CHANGELOG.md has no content under an "## Unreleased" heading.
+#
+# This repository generates its changelog automatically at release time via
+# craft (`changelogPolicy: auto` in .craft.yml), from the pull requests merged
+# since the previous release. Craft only regenerates the section when it is
+# empty, so a single leftover manual entry under "## Unreleased" suppresses the
+# auto-generation and silently drops every other change from the release notes.
+#
+# Usage: scripts/verify-changelog.sh [path-to-changelog]
+set -euo pipefail
+
+CHANGELOG="${1:-CHANGELOG.md}"
+
+if [[ ! -f "$CHANGELOG" ]]; then
+ echo "Changelog file not found: $CHANGELOG" >&2
+ exit 2
+fi
+
+# Find non-blank lines in the "## Unreleased" section -- everything between the
+# "## Unreleased" heading and the next "## " (h2) heading; "### " sub-headings
+# are not treated as section boundaries. Each match is emitted as
+# ":" so reported locations point at CHANGELOG.md.
+#
+# craft trims the section body and skips auto-generation whenever it is
+# non-empty (`if (!changeset.body)` after `.trim()`), so ANY non-whitespace
+# content under "## Unreleased" -- a bullet, a stray "### Features" sub-heading,
+# or loose text -- suppresses generation. Match that exactly: fail on any
+# non-blank line. A bare/empty "## Unreleased" heading is fine (craft
+# regenerates it).
+offending="$(awk '
+ /^## / {
+ if (in_section) { in_section = 0 }
+ if (tolower($0) ~ /^## +unreleased/) { in_section = 1; next }
+ }
+ in_section && /[^[:space:]]/ { printf "%d:%s\n", NR, $0 }
+' "$CHANGELOG")"
+
+if [[ -n "$offending" ]]; then
+ echo "::error file=$CHANGELOG::The '## Unreleased' section is not empty."
+ echo ""
+ echo "This repository generates its changelog automatically at release time"
+ echo "(changelogPolicy: auto in .craft.yml). craft only regenerates the"
+ echo "'## Unreleased' section when it is empty, so ANY leftover content there"
+ echo "(entries or even a bare sub-heading) suppresses generation and causes the"
+ echo "rest of the release notes to be dropped."
+ echo ""
+ echo "Offending line(s) in $CHANGELOG:"
+ printf '%s\n' "$offending"
+ echo ""
+ echo "Please remove them. Your change is added to the changelog automatically,"
+ echo "based on the PR title / commit message -- or a '### Changelog Entry' section"
+ echo "in the PR description if you want a more detailed entry. If it is not"
+ echo "user-facing, add the 'skip-changelog' label or write '#skip-changelog' in the"
+ echo "PR description."
+ exit 1
+fi
+
+echo "OK: '## Unreleased' section is empty."