diff --git a/.github/actions/deploy/README.md b/.github/actions/deploy/README.md index 7216034..d2713a3 100644 --- a/.github/actions/deploy/README.md +++ b/.github/actions/deploy/README.md @@ -13,6 +13,7 @@ jobs: steps: - uses: actions/checkout@v7 - uses: rubykatzen/flightdeck/.github/actions/deploy@main + id: deploy with: target-manifest: ${{ matrix.manifest }} # required, path in this repository ssh-private-key: ${{ secrets[matrix.ssh_private_key_secret] }} @@ -22,15 +23,16 @@ jobs: # tailscale-oauth-secret: ${{ secrets.TAILSCALE_OAUTH_SECRET }} # required only if tailscale-oauth-client-id is set # tailscale-tags: tag:ci # default: tag:ci - name: Notify Telegram - uses: rubykatzen/baseline/.github/actions/send-telegram-message@v0.16.1 + uses: rubykatzen/baseline/.github/actions/send-telegram-message@v0.17.0 with: - message: "Deploy: ${{ matrix.name }} updated" + message: ${{ steps.deploy.outputs.telegram-message }} telegram-bot-token: ${{ secrets.TELEGRAM_BOT_TOKEN }} telegram-chat-id: ${{ vars.TELEGRAM_CHAT_ID }} + parse-mode: MarkdownV2 ``` Unlike a `workflow_call` reusable workflow, this action doesn't check out anything itself - it reads `target-manifest` from whatever the caller's own preceding `actions/checkout` step already put on disk, and its own code (`deploy.py` and friends) comes along automatically via `$GITHUB_ACTION_PATH` whenever it's referenced as `owner/repo/.github/actions/deploy@ref`. This is also why there's no `target-manifest-ref` input here: if the caller needs a specific ref (e.g. a just-published release tag), it just checks out that ref itself before this step runs, the same way every other job in this repository already does. `sops-age-key`/`ssh-private-key` are secret *values*, resolved by the caller from the target manifest's own `sops_age_key_secret`/`ssh_private_key_secret` fields (GitHub Secret *names* - see the main README's "Vaults And Targets" section) - this action never reads the manifest's credential fields itself, since it never has access to `secrets.*` by name. -This action has no notification logic of its own, same as [`renovate`](../renovate) - notifying is the caller's job, as a plain following step. Unlike `renovate`, there's no "did anything change" output to gate it on: reaching that step at all already means the `deploy` step above it succeeded, so it just runs unconditionally. +The action prepares a successful deploy message as its `telegram-message` output, but never sends it or receives Telegram credentials. Delivery remains the caller's job, as a plain following step. Unlike `renovate`, there's no "did anything change" output to gate it on: reaching that step at all already means the `deploy` step above it succeeded, so it just runs unconditionally. diff --git a/.github/actions/deploy/action.yml b/.github/actions/deploy/action.yml index 760c524..fb8a033 100644 --- a/.github/actions/deploy/action.yml +++ b/.github/actions/deploy/action.yml @@ -25,6 +25,10 @@ inputs: description: Comma-separated ACL tags for the ephemeral tailnet node. required: false default: tag:ci +outputs: + telegram-message: + description: Successful deploy notification formatted as Telegram MarkdownV2. + value: ${{ steps.prepare-notification.outputs.message }} runs: using: composite steps: @@ -54,6 +58,7 @@ runs: echo "SSH_AGENT_PID=$SSH_AGENT_PID" >> "$GITHUB_ENV" ssh-add - <<< "${{ inputs.ssh-private-key }}" - name: Run deploy + id: run shell: bash env: TARGET_MANIFEST: ${{ inputs.target-manifest }} @@ -63,3 +68,13 @@ runs: jq -n --arg target_manifest "$TARGET_MANIFEST" --arg sops_age_key "$SOPS_AGE_KEY" \ '{target_manifest: $target_manifest, sops_age_key: $sops_age_key}' \ | python3 "$GITHUB_ACTION_PATH/deploy.py" + - name: Prepare notification + id: prepare-notification + shell: bash + env: + REPOSITORY: ${{ github.repository }} + OPERATION: deploy + TARGET: ${{ steps.run.outputs.target_name }} + RUN_URL: ${{ format('{0}/{1}/actions/runs/{2}', github.server_url, github.repository, github.run_id) }} + ITEMS: "[]" + run: python3 "$GITHUB_ACTION_PATH/../telegram-message/prepare.py" diff --git a/.github/actions/deploy/deploy.py b/.github/actions/deploy/deploy.py index 3f7024d..dbedf6f 100644 --- a/.github/actions/deploy/deploy.py +++ b/.github/actions/deploy/deploy.py @@ -15,6 +15,7 @@ it separately (see README's "deploy" section for the exact contract). """ import json +import os import shlex import shutil import sys @@ -201,6 +202,13 @@ def load_target(path): return yaml.safe_load(Path(path).read_text()) +def write_github_output(name, value): + output_path = os.environ.get("GITHUB_OUTPUT") + if output_path: + with open(output_path, "a", encoding="utf-8") as output: + output.write(f"{name}={value}\n") + + def validate_config(config): if not config.get("hosts"): raise DeployError("Config must set hosts to a non-empty list") @@ -214,7 +222,8 @@ def validate_config(config): def main(): stdin_config = json.load(sys.stdin) - config = load_target(stdin_config["target_manifest"]) + manifest_path = Path(stdin_config["target_manifest"]) + config = load_target(manifest_path) config["sops_age_key"] = stdin_config.get("sops_age_key") validate_config(config) with tempfile.TemporaryDirectory(prefix="flightdeck-deploy-") as raw_dir: @@ -236,6 +245,7 @@ def main(): for host in config["hosts"]: print(f"Deploying to {host}") deploy_to_host(host, archive_path, apps, networks, config, release_name) + write_github_output("target_name", manifest_path.stem) if __name__ == "__main__": diff --git a/.github/actions/deploy/tests/test_deploy.py b/.github/actions/deploy/tests/test_deploy.py index 5cfe501..d32eda3 100644 --- a/.github/actions/deploy/tests/test_deploy.py +++ b/.github/actions/deploy/tests/test_deploy.py @@ -1,6 +1,7 @@ import importlib.util import io import json +import os import sys import tarfile import tempfile @@ -477,6 +478,7 @@ class MainTest(unittest.TestCase): def test_reads_target_manifest_and_merges_sops_age_key(self): with tempfile.TemporaryDirectory() as directory: manifest_path = Path(directory) / "heimdall.yml" + output_path = Path(directory) / "output" manifest_path.write_text( "hosts: [deploy@host]\n" "app_refs: [owner/repo@latest]\n" @@ -485,6 +487,7 @@ def test_reads_target_manifest_and_merges_sops_age_key(self): stdin_config = {"target_manifest": str(manifest_path), "sops_age_key": "AGE-SECRET-KEY-1..."} with ( + patch.dict(os.environ, {"GITHUB_OUTPUT": str(output_path)}), patch.object(sys, "stdin", io.StringIO(json.dumps(stdin_config))), patch.object( deploy, "build_release", return_value=(Path(directory) / "release", ["owner/repo@v1.0.0"]) @@ -511,6 +514,7 @@ def test_reads_target_manifest_and_merges_sops_age_key(self): host, archive_path, apps, networks, deploy_config = fake_deploy_to_host.call_args[0][:5] expected = ("deploy@host", Path(directory) / "release.tar.gz", ["traefik"], [], config_arg) self.assertEqual((host, archive_path, apps, networks, deploy_config), expected) + self.assertEqual(output_path.read_text(), "target_name=heimdall\n") def test_raises_when_sops_age_key_missing(self): with tempfile.TemporaryDirectory() as directory: diff --git a/.github/actions/renovate/README.md b/.github/actions/renovate/README.md index ba9b602..b9ebb38 100644 --- a/.github/actions/renovate/README.md +++ b/.github/actions/renovate/README.md @@ -22,15 +22,16 @@ jobs: # tailscale-oauth-secret: ${{ secrets.TAILSCALE_OAUTH_SECRET }} # required only if tailscale-oauth-client-id is set - name: Notify Telegram if: steps.renovate.outputs.updated == 'true' - uses: rubykatzen/baseline/.github/actions/send-telegram-message@v0.16.1 + uses: rubykatzen/baseline/.github/actions/send-telegram-message@v0.17.0 with: - message: "Renovate: updated on ${{ steps.renovate.outputs.target-name }} (${{ steps.renovate.outputs.updated-hosts }})" + message: ${{ steps.renovate.outputs.telegram-message }} telegram-bot-token: ${{ secrets.TELEGRAM_BOT_TOKEN }} telegram-chat-id: ${{ vars.TELEGRAM_CHAT_ID }} + parse-mode: MarkdownV2 ``` -Not every target runs every requested app - `renovate.py` decides that itself, from the target manifest's own `apps` mapping, and simply does nothing (never opening an SSH connection) if none of the requested apps are present. To tell whether a host's image actually changed (rather than the pull being a no-op), it compares `docker compose images -q` output before and after the pull, and exposes that as this action's own `updated`/`updated-hosts`/`target-name` outputs. +Not every target runs every requested app - `renovate.py` decides that itself, from the target manifest's own `apps` mapping, and simply does nothing (never opening an SSH connection) if none of the requested apps are present. To tell whether a host's image actually changed (rather than the pull being a no-op), it compares `docker compose images -q` output before and after the pull, and exposes that as this action's own `updated`/`updated-hosts`/`updated-items`/`target-name` outputs. `updated-items` is the structured JSON form used to group updated apps by host in notifications; `updated-hosts` remains available as the original comma-separated compatibility output. -Unlike [`deploy`](../deploy) - which has no notification logic at all - this action still has none of its own either, on purpose: it only ever reports whether something changed. Sending a Telegram message (or anything else) is the *caller's* job, as a separate step reading these outputs, same as shown above - a different caller is free to wire up a different channel, or none, without forking this action. +The action prepares a host-grouped message as `telegram-message` only when something changed, but never sends it or receives Telegram credentials. Sending the message is the *caller's* job, as a separate step reading that output, same as shown above - a different caller is free to wire up a different channel, or none, without forking this action. Same checkout model as [`deploy`](../deploy): this action never checks out anything itself, it just reads `target-manifest` from whatever the caller's own preceding `actions/checkout` step already put on disk. diff --git a/.github/actions/renovate/action.yml b/.github/actions/renovate/action.yml index 7d14b3e..2179041 100644 --- a/.github/actions/renovate/action.yml +++ b/.github/actions/renovate/action.yml @@ -30,9 +30,15 @@ outputs: updated-hosts: description: Comma-separated app@host pairs that were actually updated. value: ${{ steps.run.outputs.updated_hosts }} + updated-items: + description: JSON array of app and host objects that were actually updated. + value: ${{ steps.run.outputs.updated_items }} target-name: description: This target's name, derived from the manifest's own filename. value: ${{ steps.run.outputs.target_name }} + telegram-message: + description: Successful renovation notification formatted as Telegram MarkdownV2, or empty when nothing changed. + value: ${{ steps.prepare-notification.outputs.message }} runs: using: composite steps: @@ -62,3 +68,14 @@ runs: jq -n --argjson apps "$APPS" --arg target_manifest "$TARGET_MANIFEST" \ '{apps: $apps, target_manifest: $target_manifest}' \ | python3 "$GITHUB_ACTION_PATH/renovate.py" + - name: Prepare notification + if: steps.run.outputs.updated == 'true' + id: prepare-notification + shell: bash + env: + REPOSITORY: ${{ github.repository }} + OPERATION: renovate + TARGET: ${{ steps.run.outputs.target_name }} + RUN_URL: ${{ format('{0}/{1}/actions/runs/{2}', github.server_url, github.repository, github.run_id) }} + ITEMS: ${{ steps.run.outputs.updated_items }} + run: python3 "$GITHUB_ACTION_PATH/../telegram-message/prepare.py" diff --git a/.github/actions/renovate/renovate.py b/.github/actions/renovate/renovate.py index 3702271..accb50b 100644 --- a/.github/actions/renovate/renovate.py +++ b/.github/actions/renovate/renovate.py @@ -23,10 +23,11 @@ the scheduled nightly run has no specific apps to name, so it renovates everything. -Writes `updated`/`updated_hosts`/`target_name` to $GITHUB_OUTPUT so the +Writes `updated`/`updated_hosts`/`updated_items`/`target_name` to $GITHUB_OUTPUT so the calling workflow can notify only when a host's image actually changed, rather than on every run. `updated_hosts` lists `app@host` pairs, since -more than one app may have been renovated in the same run. +more than one app may have been renovated in the same run. `updated_items` +contains the same data as structured JSON for downstream formatting. """ import json import os @@ -80,6 +81,8 @@ def main(): else: print(f"target {target_name!r} has no apps deployed, skipping") write_github_output("updated", "false") + write_github_output("updated_hosts", "") + write_github_output("updated_items", "[]") return base_path = target.get("path", "~/flightdeck") @@ -88,10 +91,11 @@ def main(): for host in target["hosts"]: print(f"Renovating {app} on {host}") if renovate_host(host, base_path, app): - updated.append(f"{app}@{host}") + updated.append({"app": app, "host": host}) write_github_output("updated", "true" if updated else "false") - write_github_output("updated_hosts", ",".join(updated)) + write_github_output("updated_hosts", ",".join(f"{item['app']}@{item['host']}" for item in updated)) + write_github_output("updated_items", json.dumps(updated, separators=(",", ":"))) if __name__ == "__main__": diff --git a/.github/actions/renovate/tests/test_renovate.py b/.github/actions/renovate/tests/test_renovate.py index af066d5..d314f4d 100644 --- a/.github/actions/renovate/tests/test_renovate.py +++ b/.github/actions/renovate/tests/test_renovate.py @@ -142,6 +142,7 @@ def fake_renovate_host(host, base_path, app): self.assertIn("updated=true\n", outputs) self.assertIn("updated_hosts=beszel@deploy@app1.example.com\n", outputs) + self.assertIn('updated_items=[{"app":"beszel","host":"deploy@app1.example.com"}]\n', outputs) def test_reports_not_updated_when_every_host_was_already_current(self): with ( @@ -152,6 +153,7 @@ def test_reports_not_updated_when_every_host_was_already_current(self): self.assertIn("updated=false\n", outputs) self.assertIn("updated_hosts=\n", outputs) + self.assertIn("updated_items=[]\n", outputs) def test_defaults_path_when_omitted(self): with ( diff --git a/.github/actions/telegram-message/prepare.py b/.github/actions/telegram-message/prepare.py new file mode 100644 index 0000000..8a1736e --- /dev/null +++ b/.github/actions/telegram-message/prepare.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +import json +import os +import uuid +from collections import OrderedDict + +MARKDOWN_V2_SPECIAL_CHARACTERS = frozenset("_*[]()~`>#+-=|{}.!\\") +TELEGRAM_MESSAGE_LIMIT = 4096 + + +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 require_text(value, name): + if not isinstance(value, str) or not value: + raise ValueError(f"{name} must be a non-empty string") + return value + + +def validate_items(items): + if not isinstance(items, list): + raise ValueError("items must be a JSON array") + + validated = [] + for index, item in enumerate(items): + if not isinstance(item, dict): + raise ValueError(f"items[{index}] must be an object") + validated.append( + { + "app": require_text(item.get("app"), f"items[{index}].app"), + "host": require_text(item.get("host"), f"items[{index}].host"), + } + ) + return validated + + +def format_items(items): + groups = OrderedDict() + for item in items: + groups.setdefault(item["host"], []).append(item["app"]) + + sections = [] + for host, apps in groups.items(): + lines = [f"*{escape_markdown(host)}*"] + lines.extend(f"• {escape_markdown(app)}" for app in apps) + sections.append("\n".join(lines)) + return "\n\n".join(sections) + + +def format_message(repository, operation, target, run_url, items=None): + repository = require_text(repository, "repository") + operation = require_text(operation, "operation") + target = require_text(target, "target") + run_url = require_text(run_url, "run-url") + items = validate_items([] if items is None else items) + header = ( + f"*{escape_markdown(repository)}* · " + f"[{escape_markdown(operation)}]({escape_link_url(run_url)}) completed · " + f"*{escape_markdown(target)}*" + ) + if not items: + return header + + message = f"{header}\n\n{format_items(items)}" + if len(message) <= TELEGRAM_MESSAGE_LIMIT: + return message + + for visible_count in range(len(items) - 1, -1, -1): + remaining = len(items) - visible_count + suffix = escape_markdown(f"…and {remaining} more") + body = format_items(items[:visible_count]) + candidate = f"{header}\n\n{body}\n\n{suffix}" if body else f"{header}\n\n{suffix}" + if len(candidate) <= TELEGRAM_MESSAGE_LIMIT: + return candidate + raise ValueError("operation notification header exceeds Telegram's message limit") + + +def write_output(message): + delimiter = f"ghdelim_{uuid.uuid4().hex}" + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output: + print(f"message<<{delimiter}", file=output) + print(message, file=output) + print(delimiter, file=output) + + +def main(): + items = json.loads(os.environ.get("ITEMS") or "[]") + message = format_message( + repository=os.environ["REPOSITORY"], + operation=os.environ["OPERATION"], + target=os.environ["TARGET"], + run_url=os.environ["RUN_URL"], + items=items, + ) + write_output(message) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/actions/telegram-message/tests/test_prepare.py b/.github/actions/telegram-message/tests/test_prepare.py new file mode 100644 index 0000000..71f1141 --- /dev/null +++ b/.github/actions/telegram-message/tests/test_prepare.py @@ -0,0 +1,112 @@ +import importlib.util +import json +import os +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +ACTION_DIR = Path(__file__).resolve().parents[1] +MODULE_PATH = ACTION_DIR / "prepare.py" +SPEC = importlib.util.spec_from_file_location("prepare_telegram_operation_message", MODULE_PATH) +prepare = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(prepare) + + +class FormatMessageTest(unittest.TestCase): + def test_formats_deploy_header(self): + message = prepare.format_message( + "dupmachine/flightdeck", + "deploy", + "mainframe", + "https://github.com/dupmachine/flightdeck/actions/runs/123", + ) + + self.assertEqual( + message, + "*dupmachine/flightdeck* · " + "[deploy](https://github.com/dupmachine/flightdeck/actions/runs/123) completed · " + "*mainframe*", + ) + + def test_groups_renovated_apps_by_host(self): + message = prepare.format_message( + "dupmachine/flightdeck", + "renovate", + "hawkeye", + "https://github.com/dupmachine/flightdeck/actions/runs/123", + [ + {"app": "gatus", "host": "root@100.75.50.2"}, + {"app": "yamtrack", "host": "root@100.75.50.2"}, + {"app": "sure", "host": "deploy@app2.example.com"}, + ], + ) + + self.assertEqual( + message, + "*dupmachine/flightdeck* · " + "[renovate](https://github.com/dupmachine/flightdeck/actions/runs/123) completed · " + "*hawkeye*\n\n" + "*root@100\\.75\\.50\\.2*\n" + "• gatus\n" + "• yamtrack\n\n" + "*deploy@app2\\.example\\.com*\n" + "• sure", + ) + + def test_escapes_dynamic_markdown(self): + message = prepare.format_message( + "owner/repo_test", + "deploy-now", + "prod.main", + "https://example.com/run/1)", + [{"app": "api_v2", "host": "root@host.example"}], + ) + + self.assertEqual( + message, + "*owner/repo\\_test* · [deploy\\-now](https://example.com/run/1\\)) completed · " + "*prod\\.main*\n\n*root@host\\.example*\n• api\\_v2", + ) + + def test_rejects_unstructured_items(self): + with self.assertRaisesRegex(ValueError, "items must be a JSON array"): + prepare.format_message("owner/repo", "renovate", "target", "https://example.com", {"app": "gatus"}) + + def test_rejects_empty_object_items(self): + with self.assertRaisesRegex(ValueError, "items must be a JSON array"): + prepare.format_message("owner/repo", "renovate", "target", "https://example.com", {}) + + def test_truncates_an_oversized_item_list(self): + items = [{"app": f"app-{index}-" + "x" * 100, "host": "root@host"} for index in range(100)] + + message = prepare.format_message("owner/repo", "renovate", "target", "https://example.com", items) + + self.assertLessEqual(len(message), prepare.TELEGRAM_MESSAGE_LIMIT) + self.assertRegex(message, r"…and \d+ more$") + + +class MainTest(unittest.TestCase): + def test_writes_multiline_message_output(self): + with tempfile.TemporaryDirectory() as directory: + output_path = Path(directory) / "output" + environment = { + "GITHUB_OUTPUT": str(output_path), + "REPOSITORY": "dupmachine/flightdeck", + "OPERATION": "renovate", + "TARGET": "hawkeye", + "RUN_URL": "https://github.com/dupmachine/flightdeck/actions/runs/123", + "ITEMS": json.dumps([{"app": "gatus", "host": "root@host"}]), + } + with patch.dict(os.environ, environment, clear=True): + prepare.main() + + output = output_path.read_text() + + self.assertIn("message< @@ -353,6 +353,7 @@ jobs: steps: - uses: actions/checkout@v7 - uses: rubykatzen/flightdeck/.github/actions/deploy@v1.0.0 + id: deploy with: target-manifest: targets/mainframe.yml # required, path in this repository ssh-private-key: ${{ secrets.DEPLOY_SSH_PRIVATE_KEY }} @@ -362,11 +363,12 @@ jobs: # tailscale-oauth-secret: ${{ secrets.TAILSCALE_OAUTH_SECRET }} # required only if tailscale-oauth-client-id is set # tailscale-tags: tag:ci # default: tag:ci - name: Notify Telegram - uses: rubykatzen/baseline/.github/actions/send-telegram-message@v1.0.0 + uses: rubykatzen/baseline/.github/actions/send-telegram-message@v0.17.0 with: - message: "Deploy: mainframe updated" + message: ${{ steps.deploy.outputs.telegram-message }} telegram-bot-token: ${{ secrets.TELEGRAM_BOT_TOKEN }} telegram-chat-id: ${{ vars.TELEGRAM_CHAT_ID }} + parse-mode: MarkdownV2 ``` @@ -396,9 +398,9 @@ Re-pulls and recreates one-or-more apps' containers on a target already present `apps` is a JSON array, so one run can renovate several apps at once (e.g. `["traefik","rybbit"]`) — pass a single-element array for the one-app case, or an empty array for every app the target runs. Not every target runs every requested app; [`renovate.py`](.github/actions/renovate/renovate.py) decides that itself from the target manifest's own `apps` mapping and simply does nothing — never opening an SSH connection — if none of the requested apps are present there. -It never touches `app_refs`/`env_refs`, never re-decrypts a vault, never rebuilds the release tree; it just picks up a new image behind an existing tag. To tell whether a host's image actually changed (rather than the pull being a no-op), it compares `docker compose images -q` output before and after the pull, and exposes `updated`/`updated-hosts`/`target-name` (derived from the manifest's own filename) as action outputs - `updated-hosts` lists `app@host` pairs, since more than one app may have been renovated in the same run. +It never touches `app_refs`/`env_refs`, never re-decrypts a vault, never rebuilds the release tree; it just picks up a new image behind an existing tag. To tell whether a host's image actually changed (rather than the pull being a no-op), it compares `docker compose images -q` output before and after the pull, and exposes `updated`/`updated-hosts`/`updated-items`/`target-name` (derived from the manifest's own filename) as action outputs. `updated-items` is a JSON array of `{app, host}` objects for structured consumers such as notification formatters; `updated-hosts` retains the original comma-separated `app@host` representation for compatibility. -Unlike `deploy`, this action has no notification logic of its own — it just reports whether anything changed. `renovate.yml` below is what actually decides to notify, as its own separate step reading this action's outputs; a different caller is free to wire up a different channel, or none at all, without forking this action. +The action prepares a host-grouped notification as its `telegram-message` output only when something changed. It never sends the message or receives Telegram credentials; `renovate.yml` below owns delivery in a separate step. A different caller can wire up another channel, or none at all, without forking the action. ```yaml jobs: @@ -420,11 +422,12 @@ jobs: # tailscale-oauth-secret: ${{ secrets.TAILSCALE_OAUTH_SECRET }} - name: Notify Telegram if: steps.renovate.outputs.updated == 'true' - uses: rubykatzen/baseline/.github/actions/send-telegram-message@v0.16.1 + uses: rubykatzen/baseline/.github/actions/send-telegram-message@v0.17.0 with: - message: "Renovate: updated on ${{ steps.renovate.outputs.target-name }} (${{ steps.renovate.outputs.updated-hosts }})" + message: ${{ steps.renovate.outputs.telegram-message }} telegram-bot-token: ${{ secrets.TELEGRAM_BOT_TOKEN }} telegram-chat-id: ${{ vars.TELEGRAM_CHAT_ID }} + parse-mode: MarkdownV2 ``` `renovate.yml` also runs on a nightly `schedule` (`0 3 * * *`), with `apps` defaulting to `[]` — a cron trigger can't supply `workflow_dispatch` inputs at all, so the empty-array-means-everything behavior above exists specifically to give the scheduled run something to pass.