Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions .github/actions/deploy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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] }}
Expand All @@ -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.
15 changes: 15 additions & 0 deletions .github/actions/deploy/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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 }}
Expand All @@ -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"
12 changes: 11 additions & 1 deletion .github/actions/deploy/deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand All @@ -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:
Expand All @@ -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__":
Expand Down
4 changes: 4 additions & 0 deletions .github/actions/deploy/tests/test_deploy.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import importlib.util
import io
import json
import os
import sys
import tarfile
import tempfile
Expand Down Expand Up @@ -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"
Expand All @@ -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"])
Expand All @@ -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:
Expand Down
9 changes: 5 additions & 4 deletions .github/actions/renovate/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
17 changes: 17 additions & 0 deletions .github/actions/renovate/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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"
12 changes: 8 additions & 4 deletions .github/actions/renovate/renovate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand All @@ -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__":
Expand Down
2 changes: 2 additions & 0 deletions .github/actions/renovate/tests/test_renovate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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 (
Expand Down
107 changes: 107 additions & 0 deletions .github/actions/telegram-message/prepare.py
Original file line number Diff line number Diff line change
@@ -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())
Loading