diff --git a/.github/scripts/extract-changelog.py b/.github/scripts/extract-changelog.py
new file mode 100755
index 000000000..65d45843a
--- /dev/null
+++ b/.github/scripts/extract-changelog.py
@@ -0,0 +1,57 @@
+#!/usr/bin/env python3
+"""Extract the latest version's release notes from docs/changelog/index.md.
+
+Outputs JSON with "version", "date", and "body" fields for use in GitHub Actions.
+"""
+
+import json
+import re
+import sys
+from pathlib import Path
+
+CHANGELOG_PATH = Path("docs/changelog/index.md")
+
+
+def extract_latest() -> dict | None:
+ """Extract the latest release entry from the changelog."""
+ text = CHANGELOG_PATH.read_text(encoding="utf-8")
+
+ # Match the first version heading: ### VERSION DATE { id="..." }
+ pattern = re.compile(
+ r'^###\s+(\S+)\s+(.*?)\s*\{[^}]*\}\s*$',
+ re.MULTILINE,
+ )
+
+ match = pattern.search(text)
+ if not match:
+ return None
+
+ version = match.group(1)
+ date = match.group(2).strip()
+ start = match.end()
+
+ # Find the next version heading or the "---" separator
+ next_pattern = re.compile(
+ r'^###\s+\S+\s+|^---\s*$',
+ re.MULTILINE,
+ )
+ next_match = next_pattern.search(text, start)
+ end = next_match.start() if next_match else len(text)
+
+ body = text[start:end].strip()
+
+ return {"version": version, "date": date, "body": body}
+
+
+def main() -> None:
+ entry = extract_latest()
+ if not entry:
+ print("No version entry found in changelog", file=sys.stderr)
+ sys.exit(1)
+
+ json.dump(entry, sys.stdout, ensure_ascii=False)
+ print() # trailing newline
+
+
+if __name__ == "__main__":
+ main()
diff --git a/.github/workflows/draft-release.yml b/.github/workflows/draft-release.yml
new file mode 100644
index 000000000..350fc9265
--- /dev/null
+++ b/.github/workflows/draft-release.yml
@@ -0,0 +1,87 @@
+name: Draft Release Notes
+
+on:
+ push:
+ branches:
+ - "main"
+ paths:
+ - "docs/changelog/index.md"
+ tags:
+ - "[0-9]+.[0-9]+.[0-9]+*"
+ workflow_dispatch:
+
+permissions:
+ contents: write
+
+jobs:
+ sync-release:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Extract latest changelog entry
+ id: changelog
+ run: |
+ python3 .github/scripts/extract-changelog.py > /tmp/changelog.json
+
+ - name: Parse version and date
+ id: meta
+ run: |
+ VERSION=$(python3 -c "import json; print(json.load(open('/tmp/changelog.json'))['version'])")
+ DATE=$(python3 -c "import json; print(json.load(open('/tmp/changelog.json'))['date'])")
+ echo "version=$VERSION" >> "$GITHUB_OUTPUT"
+ echo "date=$DATE" >> "$GITHUB_OUTPUT"
+
+ - name: Set tag and draft flag
+ id: flags
+ run: |
+ if [[ "${{ github.ref_type }}" == "tag" ]]; then
+ echo "tag=${{ github.ref_name }}" >> "$GITHUB_OUTPUT"
+ echo "draft=false" >> "$GITHUB_OUTPUT"
+ echo "make_latest=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "tag=${{ steps.meta.outputs.version }}" >> "$GITHUB_OUTPUT"
+ echo "draft=true" >> "$GITHUB_OUTPUT"
+ echo "make_latest=false" >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: Write release notes to file
+ run: |
+ python3 -c "
+ import json
+ with open('/tmp/changelog.json') as f:
+ data = json.load(f)
+ with open('/tmp/release-body.md', 'w') as f:
+ f.write(data['body'])
+ "
+
+ - name: Create or update GitHub Release
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ TAG="${{ steps.flags.outputs.tag }}"
+ TITLE="${{ steps.meta.outputs.version }} – ${{ steps.meta.outputs.date }}"
+ NOTES_FILE="/tmp/release-body.md"
+ DRAFT="${{ steps.flags.outputs.draft }}"
+ MAKE_LATEST="${{ steps.flags.outputs.make_latest }}"
+
+ ARGS=(
+ --title "$TITLE"
+ --notes-file "$NOTES_FILE"
+ )
+
+ if [[ "$DRAFT" == "true" ]]; then
+ ARGS+=(--draft)
+ fi
+
+ if [[ "$MAKE_LATEST" == "true" ]]; then
+ ARGS+=(--latest)
+ fi
+
+ if gh release view "$TAG" &>/dev/null; then
+ echo "Release $TAG exists – editing"
+ gh release edit "$TAG" "${ARGS[@]}"
+ else
+ echo "Release $TAG does not exist – creating"
+ gh release create "$TAG" "${ARGS[@]}"
+ fi
diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml
deleted file mode 100644
index 6eaab8566..000000000
--- a/.github/workflows/release-drafter.yml
+++ /dev/null
@@ -1,16 +0,0 @@
-name: Release Drafter
-
-on:
- push:
- branches:
- - "main"
- workflow_dispatch:
-
-permissions: {}
-
-jobs:
- draft-release:
- permissions:
- contents: write
- pull-requests: write
- uses: mkdocs-ng/.github/.github/workflows/release-drafter.yml@main