diff --git a/.github/workflows/notify-telegram.yml b/.github/workflows/notify-telegram.yml
new file mode 100644
index 0000000..d9e5bfb
--- /dev/null
+++ b/.github/workflows/notify-telegram.yml
@@ -0,0 +1,114 @@
+name: Notify Telegram
+
+on:
+ release:
+ types: [published]
+
+permissions:
+ contents: read
+
+jobs:
+ notify:
+ # Only post for stable releases on the canonical repo. Pre-releases
+ # (rolling builds, RCs) are skipped so the channel stays low-noise.
+ if: github.repository == 'zhom/donutbrowser' && !github.event.release.prerelease
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ ref: main
+ fetch-depth: 0
+
+ - name: Post release announcement to Telegram
+ env:
+ TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
+ TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
+ TAG: ${{ github.event.release.tag_name }}
+ REPO: ${{ github.repository }}
+ run: |
+ if [ -z "$TELEGRAM_BOT_TOKEN" ] || [ -z "$TELEGRAM_CHAT_ID" ]; then
+ echo "::warning::TELEGRAM_BOT_TOKEN or TELEGRAM_CHAT_ID is not set — skipping Telegram notification."
+ exit 0
+ fi
+
+ # Resolve the previous stable tag the same way notify-discord does
+ # so the changelog ranges line up.
+ PREV_TAG=$(git tag --sort=-version:refname \
+ | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \
+ | grep -v "^${TAG}$" \
+ | head -n 1)
+ if [ -z "$PREV_TAG" ]; then
+ PREV_TAG=$(git rev-list --max-parents=0 HEAD)
+ fi
+
+ strip_prefix() { echo "$1" | sed -E 's/^[a-z]+(\([^)]*\))?: //'; }
+
+ # Build a plain bullet list from feat / fix / refactor commits.
+ # Other commit types (chore, docs, ci, test, deps) are intentionally
+ # filtered out — same convention as the Discord embed.
+ CHANGES=""
+ while IFS= read -r msg; do
+ [ -z "$msg" ] && continue
+ case "$msg" in
+ feat\(*\):*|feat:*|fix\(*\):*|fix:*|refactor\(*\):*|refactor:*)
+ CHANGES="${CHANGES}• $(strip_prefix "$msg")"$'\n'
+ ;;
+ esac
+ done < <(git log --pretty=format:%s "${PREV_TAG}..${TAG}")
+
+ if [ -z "$CHANGES" ]; then
+ CHANGES="• See release notes."$'\n'
+ fi
+
+ # HTML-escape the changelog before injecting into Telegram HTML
+ # mode — commit messages can legitimately contain `<`, `>`, `&`.
+ # The static markup around it (we control it) is left as-is.
+ ESCAPED_CHANGES=$(printf '%s' "$CHANGES" \
+ | python3 -c "import html, sys; sys.stdout.write(html.escape(sys.stdin.read()))")
+
+ VERSION="${TAG}"
+ VERSION_NUM="${TAG#v}"
+ RELEASE_URL="https://github.com/${REPO}/releases/tag/${VERSION}"
+ DL="https://github.com/${REPO}/releases/download/${VERSION}"
+
+ # Build the API payload in one jq pass — keeps every literal
+ # newline, every angle bracket, and every quote correctly escaped
+ # for both shell and JSON.
+ PAYLOAD=$(jq -n \
+ --arg chat_id "$TELEGRAM_CHAT_ID" \
+ --arg version "$VERSION" \
+ --arg changes "$ESCAPED_CHANGES" \
+ --arg dl "$DL" \
+ --arg vnum "$VERSION_NUM" \
+ --arg release_url "$RELEASE_URL" \
+ '{
+ chat_id: $chat_id,
+ parse_mode: "HTML",
+ disable_web_page_preview: true,
+ text: (
+ "Donut Browser " + $version + " released\n\n" +
+ $changes + "\n" +
+ "Download\n" +
+ "macOS (Apple Silicon) · " +
+ "macOS (Intel)\n" +
+ "Windows x64 · " +
+ "Linux x64\n\n" +
+ "Full release notes"
+ )
+ }')
+
+ # Use --fail-with-body so we surface Telegram's error JSON on 4xx/5xx
+ # instead of just a curl exit code.
+ RESPONSE=$(curl -sSL --fail-with-body \
+ -H "Content-Type: application/json" \
+ -d "$PAYLOAD" \
+ "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage") \
+ || { echo "::error::Telegram API call failed"; echo "$RESPONSE"; exit 1; }
+
+ if [ "$(jq -r .ok <<< "$RESPONSE")" != "true" ]; then
+ echo "::error::Telegram API rejected the message:"
+ jq . <<< "$RESPONSE"
+ exit 1
+ fi
+
+ echo "Posted to Telegram (message_id $(jq -r .result.message_id <<< "$RESPONSE"))"