diff --git a/.github/actions/prepare-telegram-release-message/action.yml b/.github/actions/prepare-telegram-release-message/action.yml new file mode 100644 index 0000000..0a589a2 --- /dev/null +++ b/.github/actions/prepare-telegram-release-message/action.yml @@ -0,0 +1,18 @@ +name: Prepare Telegram release message +description: Prepare a Telegram message for a published release. +outputs: + message: + description: Prepared message. + value: ${{ steps.prepare.outputs.message }} +runs: + using: composite + steps: + - id: prepare + shell: bash + env: + REPOSITORY: ${{ github.repository }} + TAG_NAME: ${{ github.event.release.tag_name }} + RELEASE_NAME: ${{ github.event.release.name }} + RELEASE_URL: ${{ github.event.release.html_url }} + ACTOR: ${{ github.event.release.author.login }} + run: python3 "${{ github.action_path }}/prepare.py" diff --git a/.github/actions/prepare-telegram-release-message/prepare.py b/.github/actions/prepare-telegram-release-message/prepare.py new file mode 100644 index 0000000..faca63a --- /dev/null +++ b/.github/actions/prepare-telegram-release-message/prepare.py @@ -0,0 +1,50 @@ +#!/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 format_published(repository, release): + tag = release["tag"] + label = escape_markdown(f"release {tag}") + link = f"[{label}]({escape_link_url(release['url'])})" + parts = [f"*{escape_markdown(repository)}*", f"{link} published"] + + name = release.get("name") + if name and name != tag: + parts.append(f"*{escape_markdown(name)}*") + + parts.append(escape_markdown(release.get("actor") or "ghost")) + return " · ".join(parts) + + +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(): + release = { + "tag": os.environ["TAG_NAME"], + "name": os.environ.get("RELEASE_NAME") or "", + "url": os.environ["RELEASE_URL"], + "actor": os.environ.get("ACTOR") or "ghost", + } + write_output(format_published(os.environ["REPOSITORY"], release)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/workflows/notify-telegram-release-shared.yml b/.github/workflows/notify-telegram-release-shared.yml new file mode 100644 index 0000000..127c5d5 --- /dev/null +++ b/.github/workflows/notify-telegram-release-shared.yml @@ -0,0 +1,25 @@ +name: Notify Telegram release (shared) +on: + workflow_call: + secrets: + TELEGRAM_BOT_TOKEN: + required: true + TELEGRAM_CHAT_ID: + required: true +jobs: + notify: + if: >- + github.event_name == 'release' && + github.event.action == 'published' + runs-on: ubuntu-slim + permissions: {} + steps: + - id: prepare + uses: $/.github/actions/prepare-telegram-release-message + - name: Send + uses: $/.github/actions/send-telegram-message + with: + message: ${{ steps.prepare.outputs.message }} + telegram-bot-token: ${{ secrets.TELEGRAM_BOT_TOKEN }} + telegram-chat-id: ${{ secrets.TELEGRAM_CHAT_ID }} + parse-mode: MarkdownV2 diff --git a/.github/workflows/notify-telegram-release.yml b/.github/workflows/notify-telegram-release.yml new file mode 100644 index 0000000..392b973 --- /dev/null +++ b/.github/workflows/notify-telegram-release.yml @@ -0,0 +1,10 @@ +name: Notify Telegram release +on: + release: + types: [published] +jobs: + notify: + uses: ./.github/workflows/notify-telegram-release-shared.yml + secrets: + TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} + TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }} diff --git a/AGENTS.md b/AGENTS.md index 97a67ef..f41507f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -82,7 +82,7 @@ installed in the developer environment. - `.github/workflows/lint.yml`, `github.yml`, and `embedder.yml` — local self-check callers - `.github/workflows/pr.yml` — validates Baseline pull request titles against Conventional Commits - `.github/workflows/release.yml` — maintains the release PR and publishes merged releases -- `.github/workflows/notify-telegram-pr.yml` — Baseline's own Telegram pull request notification caller +- `.github/workflows/notify-telegram-pr.yml` and `notify-telegram-release.yml` — Baseline's own Telegram notification callers - `.github/workflows/test.yml` — runs the Python test suite - `.pre-commit-hooks.yaml` — hook definitions for pre-commit @@ -124,6 +124,9 @@ The caller owns any label or other notification condition; the shared workflow does not modify issues. Baseline does not call it because this repository does not use issue notifications. +`notify-telegram-release-shared.yml` exports release publication notifications. +Baseline calls it locally through `notify-telegram-release.yml`. + Pre-commit hook pins in `.pre-commit-config.yaml` are updated by Dependabot (`package-ecosystem: pre-commit` in `.github/dependabot.yml`), not by a custom workflow. diff --git a/README.md b/README.md index 0e15514..9c26f6a 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,31 @@ workflow code from the default branch. The shared workflow does not check out or execute pull request code. Pass the two secrets explicitly rather than using `secrets: inherit`. +## Notify Telegram about releases + +Create `.github/workflows/notify-telegram-release.yml`: + + + +```yaml +name: Notify Telegram release +on: + release: + types: [published] +jobs: + notify: + uses: rubykatzen/baseline/.github/workflows/notify-telegram-release-shared.yml@v0.15.0 + secrets: + TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} + TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }} +``` + + + +Baseline sends one notification when a release or prerelease is published. The +release tag links to the GitHub Release. Pass the two secrets explicitly rather +than using `secrets: inherit`. + ## Notify Telegram about closed issues Issue notifications are optional and can use a different Telegram channel from diff --git a/test/test_telegram_notifications.py b/test/test_telegram_notifications.py index 4e62557..5c8dade 100644 --- a/test/test_telegram_notifications.py +++ b/test/test_telegram_notifications.py @@ -21,6 +21,7 @@ def load_action_module(name): PR_TELEGRAM = load_action_module("prepare-telegram-pr-message") ISSUE_TELEGRAM = load_action_module("prepare-telegram-issue-message") +RELEASE_TELEGRAM = load_action_module("prepare-telegram-release-message") class TelegramPullRequestMessageTest(unittest.TestCase): @@ -189,6 +190,37 @@ def test_closed_issue_includes_leading_heading_and_first_paragraph(self): self.assertNotIn("Ignored", message) +class TelegramReleaseMessageTest(unittest.TestCase): + def test_formats_release_without_repeating_tag_as_name(self): + release = { + "tag": "v1.2.3", + "name": "v1.2.3", + "url": "https://github.com/owner/repo/releases/tag/v1.2.3", + "actor": "octocat", + } + + message = RELEASE_TELEGRAM.format_published("owner/repo", release) + + self.assertEqual( + message, + "*owner/repo* · [release v1\\.2\\.3](https://github.com/owner/repo/releases/tag/v1.2.3) " + "published · octocat", + ) + + def test_formats_distinct_release_name_and_escapes_markdown(self): + release = { + "tag": "v1.2.3", + "name": "Summer [release]", + "url": "https://github.com/owner/repo/releases/tag/v1.2.3", + "actor": "dependabot[bot]", + } + + message = RELEASE_TELEGRAM.format_published("owner/repo", release) + + self.assertIn(r"*Summer \[release\]*", message) + self.assertTrue(message.endswith(r"dependabot\[bot\]")) + + class TelegramWorkflowTest(unittest.TestCase): def load_workflow(self, name): with open(BASELINE_ROOT / ".github" / "workflows" / name) as workflow: @@ -228,30 +260,62 @@ def test_issue_workflow_only_sends_closed_issue_notification(self): ) self.assertEqual(job["steps"][-1]["with"]["parse-mode"], "MarkdownV2") - def test_baseline_caller_passes_only_explicit_telegram_secrets(self): - path = BASELINE_ROOT / ".github" / "workflows" / "notify-telegram-pr.yml" - content = path.read_text() - workflow = yaml.safe_load(content) - secrets = workflow["jobs"]["notify"]["secrets"] + def test_release_workflow_only_sends_published_release_notification(self): + workflow = self.load_workflow("notify-telegram-release-shared.yml") + job = workflow["jobs"]["notify"] + + self.assertIn("github.event_name == 'release'", job["if"]) + self.assertIn("github.event.action == 'published'", job["if"]) + self.assertEqual(job["permissions"], {}) + self.assertEqual( + [step["uses"] for step in job["steps"]], + [ + "$/.github/actions/prepare-telegram-release-message", + "$/.github/actions/send-telegram-message", + ], + ) + self.assertEqual(job["steps"][-1]["with"]["parse-mode"], "MarkdownV2") + + def test_baseline_callers_pass_only_explicit_telegram_secrets(self): + for name in ("notify-telegram-pr.yml", "notify-telegram-release.yml"): + path = BASELINE_ROOT / ".github" / "workflows" / name + content = path.read_text() + workflow = yaml.safe_load(content) + secrets = workflow["jobs"]["notify"]["secrets"] + + with self.subTest(name=name): + self.assertEqual(set(secrets), {"TELEGRAM_BOT_TOKEN", "TELEGRAM_CHAT_ID"}) + self.assertNotIn("secrets: inherit", content) - self.assertEqual(set(secrets), {"TELEGRAM_BOT_TOKEN", "TELEGRAM_CHAT_ID"}) - self.assertNotIn("secrets: inherit", content) + def test_baseline_release_caller_uses_published_event(self): + content = (BASELINE_ROOT / ".github" / "workflows" / "notify-telegram-release.yml").read_text() + + self.assertIn("release:\n types: [published]", content) + self.assertIn("uses: ./.github/workflows/notify-telegram-release-shared.yml", content) def test_internal_actions_separate_formatting_and_delivery(self): action_root = BASELINE_ROOT / ".github" / "actions" telegram_actions = sorted(path.parent.name for path in action_root.glob("*telegram*/action.yml")) pr_action = (action_root / "prepare-telegram-pr-message" / "action.yml").read_text() issue_action = (action_root / "prepare-telegram-issue-message" / "action.yml").read_text() + release_action = (action_root / "prepare-telegram-release-message" / "action.yml").read_text() send_action = (action_root / "send-telegram-message" / "action.yml").read_text() self.assertEqual( telegram_actions, - ["prepare-telegram-issue-message", "prepare-telegram-pr-message", "send-telegram-message"], + [ + "prepare-telegram-issue-message", + "prepare-telegram-pr-message", + "prepare-telegram-release-message", + "send-telegram-message", + ], ) self.assertIn('run: python3 "${{ github.action_path }}/prepare.py"', pr_action) self.assertIn('run: python3 "${{ github.action_path }}/prepare.py"', issue_action) + self.assertIn('run: python3 "${{ github.action_path }}/prepare.py"', release_action) self.assertNotIn("api.telegram.org", pr_action) self.assertNotIn("api.telegram.org", issue_action) + self.assertNotIn("api.telegram.org", release_action) self.assertIn("inputs:\n message:", send_action) self.assertIn("parse-mode:", send_action) self.assertIn("parse_mode", send_action)