From 230eee3d8ac38560cea27af082e88236b4fa7287 Mon Sep 17 00:00:00 2001 From: Yuriy Kirillov Date: Sat, 29 Aug 2026 15:00:50 +0200 Subject: [PATCH 1/2] feat: format deploy and renovate notifications --- .github/actions/deploy/README.md | 12 +- .../README.md | 32 +++++ .../action.yml | 36 ++++++ .../prepare.py | 107 +++++++++++++++++ .../tests/test_prepare.py | 112 ++++++++++++++++++ .github/actions/renovate/README.md | 16 ++- .github/actions/renovate/action.yml | 3 + .github/actions/renovate/renovate.py | 12 +- .../actions/renovate/tests/test_renovate.py | 2 + .github/workflows/deploy.yml | 12 +- .github/workflows/renovate.yml | 14 ++- README.md | 28 ++++- 12 files changed, 368 insertions(+), 18 deletions(-) create mode 100644 .github/actions/prepare-telegram-operation-message/README.md create mode 100644 .github/actions/prepare-telegram-operation-message/action.yml create mode 100644 .github/actions/prepare-telegram-operation-message/prepare.py create mode 100644 .github/actions/prepare-telegram-operation-message/tests/test_prepare.py diff --git a/.github/actions/deploy/README.md b/.github/actions/deploy/README.md index 7216034..292228e 100644 --- a/.github/actions/deploy/README.md +++ b/.github/actions/deploy/README.md @@ -21,12 +21,20 @@ jobs: # tailscale-oauth-client-id: ${{ vars.TAILSCALE_OAUTH_CLIENT_ID }} # optional, default: unset (skip joining a tailnet) # tailscale-oauth-secret: ${{ secrets.TAILSCALE_OAUTH_SECRET }} # required only if tailscale-oauth-client-id is set # tailscale-tags: tag:ci # default: tag:ci + - uses: rubykatzen/flightdeck/.github/actions/prepare-telegram-operation-message@main + id: prepare-notification + with: + repository: ${{ github.repository }} + operation: deploy + target: ${{ matrix.name }} + run-url: ${{ format('{0}/{1}/actions/runs/{2}', github.server_url, github.repository, github.run_id) }} - 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.prepare-notification.outputs.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. diff --git a/.github/actions/prepare-telegram-operation-message/README.md b/.github/actions/prepare-telegram-operation-message/README.md new file mode 100644 index 0000000..e4d3f3f --- /dev/null +++ b/.github/actions/prepare-telegram-operation-message/README.md @@ -0,0 +1,32 @@ +# prepare-telegram-operation-message + +Formats a deploy or renovate result as escaped Telegram MarkdownV2. The header shows the repository and target in bold and links the operation label to its GitHub Actions run. Optional `{app, host}` items are grouped by host below the header. + +```yaml +- uses: rubykatzen/flightdeck/.github/actions/prepare-telegram-operation-message@main + id: prepare-notification + with: + repository: ${{ github.repository }} + operation: renovate + target: ${{ steps.renovate.outputs.target-name }} + run-url: ${{ format('{0}/{1}/actions/runs/{2}', github.server_url, github.repository, github.run_id) }} + items: ${{ steps.renovate.outputs.updated-items }} +- uses: rubykatzen/baseline/.github/actions/send-telegram-message@v0.17.0 + with: + message: ${{ steps.prepare-notification.outputs.message }} + telegram-bot-token: ${{ secrets.TELEGRAM_BOT_TOKEN }} + telegram-chat-id: ${{ vars.TELEGRAM_CHAT_ID }} + parse-mode: MarkdownV2 +``` + +With two updated apps on one host, the rendered message is: + +```text +owner/repository · renovate completed · target + +root@host +• gatus +• yamtrack +``` + +The action truncates oversized item lists at a complete item boundary and reports how many entries were omitted, keeping the result within Telegram's 4096-character message limit. diff --git a/.github/actions/prepare-telegram-operation-message/action.yml b/.github/actions/prepare-telegram-operation-message/action.yml new file mode 100644 index 0000000..d4e1597 --- /dev/null +++ b/.github/actions/prepare-telegram-operation-message/action.yml @@ -0,0 +1,36 @@ +name: Prepare Telegram operation message +description: Format a deploy or renovate notification as escaped Telegram MarkdownV2. +inputs: + repository: + description: Repository where the operation ran, in owner/repo form. + required: true + operation: + description: Operation label shown in the linked header. + required: true + target: + description: Deployment target name. + required: true + run-url: + description: URL of the GitHub Actions run. + required: true + items: + description: Optional JSON array of app and host objects to group below the header. + required: false + default: "[]" +outputs: + message: + description: Escaped Telegram MarkdownV2 message. + value: ${{ steps.prepare.outputs.message }} +runs: + using: composite + steps: + - name: Prepare message + id: prepare + shell: bash + env: + REPOSITORY: ${{ inputs.repository }} + OPERATION: ${{ inputs.operation }} + TARGET: ${{ inputs.target }} + RUN_URL: ${{ inputs.run-url }} + ITEMS: ${{ inputs.items }} + run: python3 "$GITHUB_ACTION_PATH/prepare.py" diff --git a/.github/actions/prepare-telegram-operation-message/prepare.py b/.github/actions/prepare-telegram-operation-message/prepare.py new file mode 100644 index 0000000..8a1736e --- /dev/null +++ b/.github/actions/prepare-telegram-operation-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/prepare-telegram-operation-message/tests/test_prepare.py b/.github/actions/prepare-telegram-operation-message/tests/test_prepare.py new file mode 100644 index 0000000..71f1141 --- /dev/null +++ b/.github/actions/prepare-telegram-operation-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< @@ -396,7 +404,7 @@ 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. @@ -418,13 +426,23 @@ jobs: ssh-private-key: ${{ secrets[matrix.ssh_private_key_secret] }} # tailscale-oauth-client-id: ${{ vars.TAILSCALE_OAUTH_CLIENT_ID }} # tailscale-oauth-secret: ${{ secrets.TAILSCALE_OAUTH_SECRET }} + - uses: rubykatzen/flightdeck/.github/actions/prepare-telegram-operation-message@v1.0.0 + if: steps.renovate.outputs.updated == 'true' + id: prepare-notification + with: + repository: ${{ github.repository }} + operation: renovate + target: ${{ steps.renovate.outputs.target-name }} + run-url: ${{ format('{0}/{1}/actions/runs/{2}', github.server_url, github.repository, github.run_id) }} + items: ${{ steps.renovate.outputs.updated-items }} - 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.prepare-notification.outputs.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. From 470e7f0a048e8a2deebf9f0b8d3268da29fcc273 Mon Sep 17 00:00:00 2001 From: Yuriy Kirillov Date: Sat, 29 Aug 2026 15:11:27 +0200 Subject: [PATCH 2/2] refactor: encapsulate notification formatting in actions --- .github/actions/deploy/README.md | 12 ++----- .github/actions/deploy/action.yml | 15 ++++++++ .github/actions/deploy/deploy.py | 12 ++++++- .github/actions/deploy/tests/test_deploy.py | 4 +++ .../README.md | 32 ----------------- .../action.yml | 36 ------------------- .github/actions/renovate/README.md | 13 ++----- .github/actions/renovate/action.yml | 14 ++++++++ .../prepare.py | 0 .../tests/test_prepare.py | 0 .github/workflows/deploy.yml | 10 ++---- .github/workflows/renovate.yml | 11 +----- README.md | 25 +++---------- 13 files changed, 57 insertions(+), 127 deletions(-) delete mode 100644 .github/actions/prepare-telegram-operation-message/README.md delete mode 100644 .github/actions/prepare-telegram-operation-message/action.yml rename .github/actions/{prepare-telegram-operation-message => telegram-message}/prepare.py (100%) rename .github/actions/{prepare-telegram-operation-message => telegram-message}/tests/test_prepare.py (100%) diff --git a/.github/actions/deploy/README.md b/.github/actions/deploy/README.md index 292228e..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] }} @@ -21,17 +22,10 @@ jobs: # tailscale-oauth-client-id: ${{ vars.TAILSCALE_OAUTH_CLIENT_ID }} # optional, default: unset (skip joining a tailnet) # tailscale-oauth-secret: ${{ secrets.TAILSCALE_OAUTH_SECRET }} # required only if tailscale-oauth-client-id is set # tailscale-tags: tag:ci # default: tag:ci - - uses: rubykatzen/flightdeck/.github/actions/prepare-telegram-operation-message@main - id: prepare-notification - with: - repository: ${{ github.repository }} - operation: deploy - target: ${{ matrix.name }} - run-url: ${{ format('{0}/{1}/actions/runs/{2}', github.server_url, github.repository, github.run_id) }} - name: Notify Telegram uses: rubykatzen/baseline/.github/actions/send-telegram-message@v0.17.0 with: - message: ${{ steps.prepare-notification.outputs.message }} + message: ${{ steps.deploy.outputs.telegram-message }} telegram-bot-token: ${{ secrets.TELEGRAM_BOT_TOKEN }} telegram-chat-id: ${{ vars.TELEGRAM_CHAT_ID }} parse-mode: MarkdownV2 @@ -41,4 +35,4 @@ Unlike a `workflow_call` reusable workflow, this action doesn't check out anythi `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/prepare-telegram-operation-message/README.md b/.github/actions/prepare-telegram-operation-message/README.md deleted file mode 100644 index e4d3f3f..0000000 --- a/.github/actions/prepare-telegram-operation-message/README.md +++ /dev/null @@ -1,32 +0,0 @@ -# prepare-telegram-operation-message - -Formats a deploy or renovate result as escaped Telegram MarkdownV2. The header shows the repository and target in bold and links the operation label to its GitHub Actions run. Optional `{app, host}` items are grouped by host below the header. - -```yaml -- uses: rubykatzen/flightdeck/.github/actions/prepare-telegram-operation-message@main - id: prepare-notification - with: - repository: ${{ github.repository }} - operation: renovate - target: ${{ steps.renovate.outputs.target-name }} - run-url: ${{ format('{0}/{1}/actions/runs/{2}', github.server_url, github.repository, github.run_id) }} - items: ${{ steps.renovate.outputs.updated-items }} -- uses: rubykatzen/baseline/.github/actions/send-telegram-message@v0.17.0 - with: - message: ${{ steps.prepare-notification.outputs.message }} - telegram-bot-token: ${{ secrets.TELEGRAM_BOT_TOKEN }} - telegram-chat-id: ${{ vars.TELEGRAM_CHAT_ID }} - parse-mode: MarkdownV2 -``` - -With two updated apps on one host, the rendered message is: - -```text -owner/repository · renovate completed · target - -root@host -• gatus -• yamtrack -``` - -The action truncates oversized item lists at a complete item boundary and reports how many entries were omitted, keeping the result within Telegram's 4096-character message limit. diff --git a/.github/actions/prepare-telegram-operation-message/action.yml b/.github/actions/prepare-telegram-operation-message/action.yml deleted file mode 100644 index d4e1597..0000000 --- a/.github/actions/prepare-telegram-operation-message/action.yml +++ /dev/null @@ -1,36 +0,0 @@ -name: Prepare Telegram operation message -description: Format a deploy or renovate notification as escaped Telegram MarkdownV2. -inputs: - repository: - description: Repository where the operation ran, in owner/repo form. - required: true - operation: - description: Operation label shown in the linked header. - required: true - target: - description: Deployment target name. - required: true - run-url: - description: URL of the GitHub Actions run. - required: true - items: - description: Optional JSON array of app and host objects to group below the header. - required: false - default: "[]" -outputs: - message: - description: Escaped Telegram MarkdownV2 message. - value: ${{ steps.prepare.outputs.message }} -runs: - using: composite - steps: - - name: Prepare message - id: prepare - shell: bash - env: - REPOSITORY: ${{ inputs.repository }} - OPERATION: ${{ inputs.operation }} - TARGET: ${{ inputs.target }} - RUN_URL: ${{ inputs.run-url }} - ITEMS: ${{ inputs.items }} - run: python3 "$GITHUB_ACTION_PATH/prepare.py" diff --git a/.github/actions/renovate/README.md b/.github/actions/renovate/README.md index 8b327c9..b9ebb38 100644 --- a/.github/actions/renovate/README.md +++ b/.github/actions/renovate/README.md @@ -20,20 +20,11 @@ jobs: ssh-private-key: ${{ secrets[matrix.ssh_private_key_secret] }} # tailscale-oauth-client-id: ${{ vars.TAILSCALE_OAUTH_CLIENT_ID }} # optional, default: unset (skip joining a tailnet) # tailscale-oauth-secret: ${{ secrets.TAILSCALE_OAUTH_SECRET }} # required only if tailscale-oauth-client-id is set - - uses: rubykatzen/flightdeck/.github/actions/prepare-telegram-operation-message@main - if: steps.renovate.outputs.updated == 'true' - id: prepare-notification - with: - repository: ${{ github.repository }} - operation: renovate - target: ${{ steps.renovate.outputs.target-name }} - run-url: ${{ format('{0}/{1}/actions/runs/{2}', github.server_url, github.repository, github.run_id) }} - items: ${{ steps.renovate.outputs.updated-items }} - name: Notify Telegram if: steps.renovate.outputs.updated == 'true' uses: rubykatzen/baseline/.github/actions/send-telegram-message@v0.17.0 with: - message: ${{ steps.prepare-notification.outputs.message }} + message: ${{ steps.renovate.outputs.telegram-message }} telegram-bot-token: ${{ secrets.TELEGRAM_BOT_TOKEN }} telegram-chat-id: ${{ vars.TELEGRAM_CHAT_ID }} parse-mode: MarkdownV2 @@ -41,6 +32,6 @@ jobs: 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 8418aa9..2179041 100644 --- a/.github/actions/renovate/action.yml +++ b/.github/actions/renovate/action.yml @@ -36,6 +36,9 @@ outputs: 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: @@ -65,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/prepare-telegram-operation-message/prepare.py b/.github/actions/telegram-message/prepare.py similarity index 100% rename from .github/actions/prepare-telegram-operation-message/prepare.py rename to .github/actions/telegram-message/prepare.py diff --git a/.github/actions/prepare-telegram-operation-message/tests/test_prepare.py b/.github/actions/telegram-message/tests/test_prepare.py similarity index 100% rename from .github/actions/prepare-telegram-operation-message/tests/test_prepare.py rename to .github/actions/telegram-message/tests/test_prepare.py diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 44e37d5..b0c22db 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -21,6 +21,7 @@ jobs: steps: - uses: actions/checkout@v7 - uses: $/.github/actions/deploy + id: deploy with: target-manifest: ${{ matrix.manifest }} ssh-private-key: ${{ secrets[matrix.ssh_private_key_secret] }} @@ -28,17 +29,10 @@ jobs: github-token: ${{ secrets.GITHUB_TOKEN }} tailscale-oauth-client-id: ${{ vars.TAILSCALE_OAUTH_CLIENT_ID }} tailscale-oauth-secret: ${{ secrets.TAILSCALE_OAUTH_SECRET }} - - uses: $/.github/actions/prepare-telegram-operation-message - id: prepare-notification - with: - repository: ${{ github.repository }} - operation: deploy - target: ${{ matrix.name }} - run-url: ${{ format('{0}/{1}/actions/runs/{2}', github.server_url, github.repository, github.run_id) }} - name: Notify Telegram uses: rubykatzen/baseline/.github/actions/send-telegram-message@v0.17.0 with: - message: ${{ steps.prepare-notification.outputs.message }} + message: ${{ steps.deploy.outputs.telegram-message }} telegram-bot-token: ${{ secrets.TELEGRAM_BOT_TOKEN }} telegram-chat-id: ${{ vars.TELEGRAM_CHAT_ID }} parse-mode: MarkdownV2 diff --git a/.github/workflows/renovate.yml b/.github/workflows/renovate.yml index 6f7024e..5582260 100644 --- a/.github/workflows/renovate.yml +++ b/.github/workflows/renovate.yml @@ -35,20 +35,11 @@ jobs: ssh-private-key: ${{ secrets[matrix.ssh_private_key_secret] }} tailscale-oauth-client-id: ${{ vars.TAILSCALE_OAUTH_CLIENT_ID }} tailscale-oauth-secret: ${{ secrets.TAILSCALE_OAUTH_SECRET }} - - uses: $/.github/actions/prepare-telegram-operation-message - if: steps.renovate.outputs.updated == 'true' - id: prepare-notification - with: - repository: ${{ github.repository }} - operation: renovate - target: ${{ steps.renovate.outputs.target-name }} - run-url: ${{ format('{0}/{1}/actions/runs/{2}', github.server_url, github.repository, github.run_id) }} - items: ${{ steps.renovate.outputs.updated-items }} - name: Notify Telegram if: steps.renovate.outputs.updated == 'true' uses: rubykatzen/baseline/.github/actions/send-telegram-message@v0.17.0 with: - message: ${{ steps.prepare-notification.outputs.message }} + message: ${{ steps.renovate.outputs.telegram-message }} telegram-bot-token: ${{ secrets.TELEGRAM_BOT_TOKEN }} telegram-chat-id: ${{ vars.TELEGRAM_CHAT_ID }} parse-mode: MarkdownV2 diff --git a/README.md b/README.md index dc7da47..a2a90a5 100644 --- a/README.md +++ b/README.md @@ -342,7 +342,7 @@ The interface is a single path, not flattened deploy vocabulary — the caller n Tailscale is optional, not a dependency of this action: set `tailscale-oauth-client-id` (and the matching `tailscale-oauth-secret`) to have the runner join a tailnet as an ephemeral node before deploying. Leave both unset to skip that step entirely — e.g. when the job already runs on a self-hosted runner with network access to the hosts, or reaches them some other way. -Like [`renovate`](#renovate), this action has no notification logic of its own - unlike `renovate`, a deploy doesn't need a "did anything actually change" check to decide whether to notify: reaching this point at all already means a real deploy just landed, so the caller's own following step notifies unconditionally (it simply never runs if the `deploy` step above it failed, same as any other step in a job). +The action prepares a successful deploy notification as its `telegram-message` output. It never sends the message or receives Telegram credentials; the caller's following step owns delivery. Unlike `renovate`, a deploy doesn't need a "did anything actually change" check: reaching that step already means a real deploy just landed, so the caller notifies unconditionally (the step simply never runs if `deploy` failed). @@ -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 }} @@ -361,17 +362,10 @@ jobs: # tailscale-oauth-client-id: ${{ vars.TAILSCALE_OAUTH_CLIENT_ID }} # optional, default: unset (skip joining a tailnet) # tailscale-oauth-secret: ${{ secrets.TAILSCALE_OAUTH_SECRET }} # required only if tailscale-oauth-client-id is set # tailscale-tags: tag:ci # default: tag:ci - - uses: rubykatzen/flightdeck/.github/actions/prepare-telegram-operation-message@v1.0.0 - id: prepare-notification - with: - repository: ${{ github.repository }} - operation: deploy - target: mainframe - run-url: ${{ format('{0}/{1}/actions/runs/{2}', github.server_url, github.repository, github.run_id) }} - name: Notify Telegram uses: rubykatzen/baseline/.github/actions/send-telegram-message@v0.17.0 with: - message: ${{ steps.prepare-notification.outputs.message }} + message: ${{ steps.deploy.outputs.telegram-message }} telegram-bot-token: ${{ secrets.TELEGRAM_BOT_TOKEN }} telegram-chat-id: ${{ vars.TELEGRAM_CHAT_ID }} parse-mode: MarkdownV2 @@ -406,7 +400,7 @@ Re-pulls and recreates one-or-more apps' containers on a target already present 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: @@ -426,20 +420,11 @@ jobs: ssh-private-key: ${{ secrets[matrix.ssh_private_key_secret] }} # tailscale-oauth-client-id: ${{ vars.TAILSCALE_OAUTH_CLIENT_ID }} # tailscale-oauth-secret: ${{ secrets.TAILSCALE_OAUTH_SECRET }} - - uses: rubykatzen/flightdeck/.github/actions/prepare-telegram-operation-message@v1.0.0 - if: steps.renovate.outputs.updated == 'true' - id: prepare-notification - with: - repository: ${{ github.repository }} - operation: renovate - target: ${{ steps.renovate.outputs.target-name }} - run-url: ${{ format('{0}/{1}/actions/runs/{2}', github.server_url, github.repository, github.run_id) }} - items: ${{ steps.renovate.outputs.updated-items }} - name: Notify Telegram if: steps.renovate.outputs.updated == 'true' uses: rubykatzen/baseline/.github/actions/send-telegram-message@v0.17.0 with: - message: ${{ steps.prepare-notification.outputs.message }} + message: ${{ steps.renovate.outputs.telegram-message }} telegram-bot-token: ${{ secrets.TELEGRAM_BOT_TOKEN }} telegram-chat-id: ${{ vars.TELEGRAM_CHAT_ID }} parse-mode: MarkdownV2