From 75b188c6f4fa565d6963bfcabe2c736f802ff489 Mon Sep 17 00:00:00 2001 From: Yuriy Kirillov Date: Wed, 2 Sep 2026 23:50:53 +0200 Subject: [PATCH] fix: align LinkedIn publish Telegram notification with other formats Replaces the raw urn:li:share:... URN and em-dash separator with the same bold-repo + bullet + link style used by the PR/issue/release notifications, via a dedicated prepare-telegram-linkedin-message action (mirroring prepare-telegram-release-message). The success message now links to the actual LinkedIn post instead of showing its opaque URN. Also fixes unescaped MarkdownV2 special characters (e.g. "." in tags, "-" in repo names) that would have broken the message under parse-mode: MarkdownV2 if built inline in the workflow YAML. Co-Authored-By: Claude Sonnet 5 --- .../action.yml | 28 ++++++++++ .../prepare.py | 55 +++++++++++++++++++ .../publish-linkedin-release-shared.yml | 23 +++++--- test/test_linkedin_release.py | 53 +++++++++++++++++- test/test_telegram_notifications.py | 1 + 5 files changed, 152 insertions(+), 8 deletions(-) create mode 100644 .github/actions/prepare-telegram-linkedin-message/action.yml create mode 100644 .github/actions/prepare-telegram-linkedin-message/prepare.py diff --git a/.github/actions/prepare-telegram-linkedin-message/action.yml b/.github/actions/prepare-telegram-linkedin-message/action.yml new file mode 100644 index 0000000..6a6e615 --- /dev/null +++ b/.github/actions/prepare-telegram-linkedin-message/action.yml @@ -0,0 +1,28 @@ +name: Prepare Telegram LinkedIn notification +description: Prepare a Telegram message reporting whether a LinkedIn release post succeeded or failed. +inputs: + result: + description: Outcome of the LinkedIn publish job (success or failure). + required: true + post-id: + description: Published LinkedIn post URN. Required when result is success. + default: "" + run-url: + description: Workflow run URL. Required when result is failure. + default: "" +outputs: + message: + description: Prepared Telegram message text. + value: ${{ steps.prepare.outputs.message }} +runs: + using: composite + steps: + - id: prepare + shell: bash + env: + REPOSITORY: ${{ github.repository }} + TAG_NAME: ${{ github.event.release.tag_name }} + RESULT: ${{ inputs.result }} + POST_ID: ${{ inputs.post-id }} + RUN_URL: ${{ inputs.run-url }} + run: python3 "${{ github.action_path }}/prepare.py" diff --git a/.github/actions/prepare-telegram-linkedin-message/prepare.py b/.github/actions/prepare-telegram-linkedin-message/prepare.py new file mode 100644 index 0000000..61622c5 --- /dev/null +++ b/.github/actions/prepare-telegram-linkedin-message/prepare.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +import os +import uuid + +MARKDOWN_V2_SPECIAL_CHARACTERS = frozenset("_*[]()~`>#+-=|{}.!\\") + + +def escape_markdown(value): + return "".join(f"\\{character}" if character in MARKDOWN_V2_SPECIAL_CHARACTERS else character for character in str(value)) + + +def escape_link_url(value): + return str(value).replace("\\", "\\\\").replace(")", "\\)") + + +def post_url(post_id): + return f"https://www.linkedin.com/feed/update/{post_id}/" + + +def format_published(repository, tag, post_id): + link = f"[LinkedIn post]({escape_link_url(post_url(post_id))})" + return f"*{escape_markdown(repository)}* • {link} published for {escape_markdown(tag)}" + + +def format_failed(repository, tag, run_url): + link = f"[LinkedIn post]({escape_link_url(run_url)})" + return f"*{escape_markdown(repository)}* • {link} failed for {escape_markdown(tag)}" + + +def write_output(message): + delimiter = f"ghdelim_{uuid.uuid4().hex}" + with open(os.environ["GITHUB_OUTPUT"], "a") as output: + print(f"message<<{delimiter}", file=output) + print(message, file=output) + print(delimiter, file=output) + + +def main(): + repository = os.environ["REPOSITORY"] + tag = os.environ["TAG_NAME"] + result = os.environ["RESULT"] + + if result == "success": + message = format_published(repository, tag, os.environ["POST_ID"]) + elif result == "failure": + message = format_failed(repository, tag, os.environ["RUN_URL"]) + else: + raise ValueError(f"result must be success or failure, got {result!r}") + + write_output(message) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/workflows/publish-linkedin-release-shared.yml b/.github/workflows/publish-linkedin-release-shared.yml index fe50931..513ce30 100644 --- a/.github/workflows/publish-linkedin-release-shared.yml +++ b/.github/workflows/publish-linkedin-release-shared.yml @@ -52,22 +52,31 @@ jobs: env: TELEGRAM_BOT_TOKEN: ${{ secrets.telegram-bot-token }} run: echo "present=${TELEGRAM_BOT_TOKEN:+true}" >> "$GITHUB_OUTPUT" + - id: prepare-success + if: needs.publish.result == 'success' && steps.check-token.outputs.present == 'true' + uses: $/.github/actions/prepare-telegram-linkedin-message + with: + result: success + post-id: ${{ needs.publish.outputs.post-id }} - name: Notify success if: needs.publish.result == 'success' && steps.check-token.outputs.present == 'true' uses: $/.github/actions/send-telegram-message with: - message: >- - ${{ github.repository }} — LinkedIn post published for - ${{ github.event.release.tag_name }}: ${{ needs.publish.outputs.post-id }} + message: ${{ steps.prepare-success.outputs.message }} telegram-bot-token: ${{ secrets.telegram-bot-token }} telegram-chat-id: ${{ inputs.telegram-chat-id }} + parse-mode: MarkdownV2 + - id: prepare-failure + if: needs.publish.result == 'failure' && steps.check-token.outputs.present == 'true' + uses: $/.github/actions/prepare-telegram-linkedin-message + with: + result: failure + run-url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - name: Notify failure if: needs.publish.result == 'failure' && steps.check-token.outputs.present == 'true' uses: $/.github/actions/send-telegram-message with: - message: >- - ${{ github.repository }} — failed to publish LinkedIn post for - ${{ github.event.release.tag_name }}: - ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + message: ${{ steps.prepare-failure.outputs.message }} telegram-bot-token: ${{ secrets.telegram-bot-token }} telegram-chat-id: ${{ inputs.telegram-chat-id }} + parse-mode: MarkdownV2 diff --git a/test/test_linkedin_release.py b/test/test_linkedin_release.py index ca0bebb..262f9c5 100644 --- a/test/test_linkedin_release.py +++ b/test/test_linkedin_release.py @@ -22,6 +22,7 @@ def load_action_module(action, module): PREPARE = load_action_module("prepare-linkedin-release-post", "prepare.py") SEND = load_action_module("send-linkedin-post", "send.py") +PREPARE_NOTIFY = load_action_module("prepare-telegram-linkedin-message", "prepare.py") class FakeResponse: @@ -238,6 +239,46 @@ def test_http_error_diagnostic_does_not_include_token(self): self.assertEqual(detail, 'LinkedIn API request failed with HTTP 401: {"message":"Expired token"}') +class LinkedInNotifyMessageTest(unittest.TestCase): + def test_formats_success_message_with_post_link(self): + message = PREPARE_NOTIFY.format_published("owner/repo", "v0.18.0", "urn:li:share:123") + + self.assertEqual( + message, + r"*owner/repo* • [LinkedIn post](https://www.linkedin.com/feed/update/urn:li:share:123/) " + r"published for v0\.18\.0", + ) + + def test_formats_failure_message_with_run_link(self): + message = PREPARE_NOTIFY.format_failed( + "owner/repo", "v0.18.0", "https://github.com/owner/repo/actions/runs/123" + ) + + self.assertEqual( + message, + r"*owner/repo* • [LinkedIn post](https://github.com/owner/repo/actions/runs/123) " + r"failed for v0\.18\.0", + ) + + def test_escapes_markdown_special_characters_in_repository_and_tag(self): + message = PREPARE_NOTIFY.format_published("owner/my-repo", "v0.18.0-rc.1", "urn:li:share:123") + + self.assertIn(r"owner/my\-repo", message) + self.assertIn(r"v0\.18\.0\-rc\.1", message) + + def test_rejects_unknown_result(self): + env = {"REPOSITORY": "owner/repo", "TAG_NAME": "v0.18.0", "RESULT": "cancelled"} + with patch.dict(os.environ, env), self.assertRaisesRegex(ValueError, "success or failure"): + PREPARE_NOTIFY.main() + + def test_writes_multiline_github_output(self): + with tempfile.NamedTemporaryFile() as output, patch.dict(os.environ, {"GITHUB_OUTPUT": output.name}): + PREPARE_NOTIFY.write_output("first\nsecond") + value = Path(output.name).read_text() + + self.assertRegex(value, r"^message<