diff --git a/.github/workflows/release-gate.yml b/.github/workflows/release-gate.yml
new file mode 100644
index 00000000..cb5fd6c0
--- /dev/null
+++ b/.github/workflows/release-gate.yml
@@ -0,0 +1,81 @@
+name: Release Gate
+
+on:
+  pull_request:
+    branches:
+      - main
+    types: [opened, synchronize, reopened, ready_for_review]
+
+jobs:
+  milestone-closure-check:
+    name: Verify all active milestones are complete
+    runs-on: ubuntu-latest
+    permissions:
+      contents: read
+      pull-requests: write
+
+    steps:
+      - name: Check for partially complete milestones
+        id: gate
+        env:
+          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+        run: |
+          echo "Checking milestone completion status..."
+
+          # Fetch all open milestones; flag any that are partially complete
+          # (have both closed AND open issues — meaning work started but not finished)
+          PARTIAL=$(gh api repos/${{ github.repository }}/milestones \
+            --jq '[.[] | select(.open_issues > 0 and .closed_issues > 0)] | 
+                  map("  ❌ \(.title): \(.open_issues) open issue(s) remaining") | .[]')
+
+          if [ -n "$PARTIAL" ]; then
+            echo "GATE_PASSED=false" >> "$GITHUB_OUTPUT"
+            echo "PARTIAL_MILESTONES<<EOF" >> "$GITHUB_OUTPUT"
+            echo "$PARTIAL" >> "$GITHUB_OUTPUT"
+            echo "EOF" >> "$GITHUB_OUTPUT"
+          else
+            echo "GATE_PASSED=true" >> "$GITHUB_OUTPUT"
+          fi
+
+      - name: Comment on PR if blocked
+        if: steps.gate.outputs.GATE_PASSED == 'false'
+        uses: actions/github-script@v7
+        with:
+          script: |
+            const partial = `${{ steps.gate.outputs.PARTIAL_MILESTONES }}`;
+            const body = [
+              '## ❌ Release Gate Blocked',
+              '',
+              'This PR cannot be merged to `main` because one or more sprint milestones',
+              'have open issues. All milestone issues must be **closed (Done)** before releasing.',
+              '',
+              '### Milestones with open issues',
+              partial,
+              '',
+              '**Next steps:**',
+              '- Close or move the remaining issues listed above.',
+              '- If an issue is deferred to a future sprint, reassign it to the next milestone.',
+              '- Once all active milestones are complete, this check will pass automatically.',
+            ].join('\n');
+
+            await github.rest.issues.createComment({
+              owner: context.repo.owner,
+              repo: context.repo.repo,
+              issue_number: context.issue.number,
+              body,
+            });
+
+      - name: Fail if gate did not pass
+        if: steps.gate.outputs.GATE_PASSED == 'false'
+        run: |
+          echo ""
+          echo "❌ Release gate FAILED. Partially complete milestones detected:"
+          echo "${{ steps.gate.outputs.PARTIAL_MILESTONES }}"
+          echo ""
+          echo "Close or reassign all open issues before merging to main."
+          exit 1
+
+      - name: Gate passed
+        if: steps.gate.outputs.GATE_PASSED == 'true'
+        run: |
+          echo "✅ Release gate PASSED — all active milestones are complete."
diff --git a/.squad/playbooks/release-myblog.md b/.squad/playbooks/release-myblog.md
index 4be51b24..b6a60cd7 100644
--- a/.squad/playbooks/release-myblog.md
+++ b/.squad/playbooks/release-myblog.md
@@ -21,7 +21,7 @@ Use this playbook when MyBlog is ready to move validated work from `dev` to
 | **Release Branch** | `main` | Release-only branch |
 | **Hotfix Branches** | `hotfix/*` | Branch from `main`, then backport to `dev` |
 | **Versioning** | `GitVersion.yml` | SemVer labels from branch + git history |
-| **Active Workflows** | `ci.yml`, `hotfix-backport-reminder.yml` | No automated release/promote workflow today |
+| **Active Workflows** | `ci.yml`, `release-gate.yml`, `hotfix-backport-reminder.yml` | `release-gate.yml` blocks merge to `main` if any active milestone has open issues |
 | **Published Artifacts** | None automated | No NuGet, Docker, docs, or deploy workflow yet |
 | **GitHub Release** | Optional manual step | Useful for notes and tags; does not deploy anything |
 
@@ -36,6 +36,20 @@ Use this playbook when MyBlog is ready to move validated work from `dev` to
   `dev` already contains the source changes. Only hotfixes merged to `main`
   need a backport to `dev`
 
+## Release readiness gate (automated)
+
+`release-gate.yml` runs on every PR targeting `main`. It blocks merge if any
+milestone has **both open and closed issues** (i.e., a sprint that started but
+is not fully done).
+
+**Gate logic:**
+- Milestone with only open issues → future sprint, not yet started → **allowed**
+- Milestone with only closed issues → complete sprint → **allowed**
+- Milestone with open AND closed issues → partially done sprint → **blocked**
+
+To unblock: close the remaining open issues, or reassign them to a future
+milestone. The check re-runs automatically when the PR is updated.
+
 ## Standard release path (`dev` → `main`)
 
 ### 1. Verify `dev` is release-ready
@@ -44,6 +58,8 @@ Before opening a release PR, confirm:
 
 - All intended `squad/*` work is already merged into `dev`
 - The latest `dev` commit is green in GitHub Actions (`ci.yml`)
+- **Project #4 board shows zero open issues for the milestone(s) being released**
+  (the `release-gate.yml` workflow enforces this automatically on the PR)
 - Any release notes summary is ready for the PR body or GitHub Release notes
 - No emergency hotfix backports are still missing from `dev`
 
