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
2 changes: 2 additions & 0 deletions .github/actions/deploy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,6 @@ 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.

`github-token` does double duty: on the runner it resolves/downloads release refs, and it's also forwarded over the same SSH connection used to push the release, to `docker login ghcr.io` on each host before `docker compose pull` - enough to pull a private image published from a different repository under the same org/user, as long as that package's own "Manage Actions access" setting grants read to this workflow's repository. That login is scoped to a throwaway `DOCKER_CONFIG` directory, removed once the pulls finish, rather than the host's default `~/.docker/config.json` - see the main README's "Automated Deploy" section.

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.
2 changes: 1 addition & 1 deletion .github/actions/deploy/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ inputs:
description: Private SOPS age key used to decrypt this target's vault-sourced env, on the runner.
required: true
github-token:
description: Token used to resolve/download release refs (app bundles, encrypted env sources).
description: Token used to resolve/download release refs (app bundles, encrypted env sources), and forwarded to each host to `docker login ghcr.io` so a private GHCR image can be pulled. For the latter, the underlying package's own "Manage Actions access" setting must grant read to this workflow's repository, since GITHUB_TOKEN only carries that far.
required: true
tailscale-oauth-client-id:
description: Tailscale OAuth client ID used to join the tailnet. Leave unset to skip joining a tailnet (e.g. when the runner already has network access to the hosts).
Expand Down
63 changes: 50 additions & 13 deletions .github/actions/deploy/deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
secret value, not YAML-safe config - the calling `deploy` action passes
it separately (see README's "deploy" section for the exact contract).
"""
import io
import json
import os
import shlex
Expand Down Expand Up @@ -140,6 +141,34 @@ def bootstrap_host(connection, base_path, networks):
connection.run(f"docker network create {shlex.quote(network)} >/dev/null 2>&1 || true", hide=True)


def login_registry(connection):
"""Log into ghcr.io using the Actions-run GITHUB_TOKEN, so `docker
compose pull` can reach private GHCR images published from another
repository under the same org/user - that package's own "Manage
Actions access" setting must grant read to this workflow's
repository, since GITHUB_TOKEN only carries that far.

Scoped to a throwaway DOCKER_CONFIG directory rather than the host's
default ~/.docker/config.json - a plain `docker login` would persist
the short-lived token past this run and silently replace whatever
ghcr.io credential was already configured on the host. Returns the
directory to export as DOCKER_CONFIG for the pull that follows (the
caller removes it once done), or None when no token is available -
nothing to log into, nothing to clean up."""
token = os.environ.get("GH_TOKEN")
if not token:
return None
docker_config = connection.run("mktemp -d", hide=True).stdout.strip()
actor = os.environ.get("GITHUB_ACTOR", "flightdeck")
connection.run(
f"DOCKER_CONFIG={shlex.quote(docker_config)} "
f"docker login ghcr.io -u {shlex.quote(actor)} --password-stdin",
hide=True,
in_stream=io.StringIO(token),
)
Comment on lines +163 to +168
return docker_config


def push_release(connection, archive_path, release_path):
connection.run(f"mkdir -p {shlex.quote(release_path)}", hide=True)
connection.put(str(archive_path), remote=f"{release_path}.tar.gz")
Expand Down Expand Up @@ -180,19 +209,27 @@ def deploy_to_host(host, archive_path, apps, networks, config, release_name):
release_path = f"{releases_path}/{release_name}"

bootstrap_host(connection, base_path, networks)
push_release(connection, archive_path, release_path)
for app in apps:
data_dir = f"{base_path}/apps-data/{app}"
connection.run(f"mkdir -p {shlex.quote(data_dir)}", hide=True)
env_path = f"{release_path}/apps/{app}/.env"
connection.run(f"echo {shlex.quote(f'DATA_DIR={data_dir}')} >> {shlex.quote(env_path)}", hide=True)

stop_removed_apps(connection, current_path, apps)
connection.run(f"ln -sfn {shlex.quote(release_path)} {shlex.quote(current_path)}", hide=True)

for app in apps:
compose_dir = f"{current_path}/apps/{app}"
connection.run(f"cd {shlex.quote(compose_dir)} && docker compose pull && docker compose up -d --remove-orphans")
docker_config = login_registry(connection)
try:
push_release(connection, archive_path, release_path)
for app in apps:
data_dir = f"{base_path}/apps-data/{app}"
connection.run(f"mkdir -p {shlex.quote(data_dir)}", hide=True)
env_path = f"{release_path}/apps/{app}/.env"
connection.run(f"echo {shlex.quote(f'DATA_DIR={data_dir}')} >> {shlex.quote(env_path)}", hide=True)

stop_removed_apps(connection, current_path, apps)
connection.run(f"ln -sfn {shlex.quote(release_path)} {shlex.quote(current_path)}", hide=True)

pull_env = f"DOCKER_CONFIG={shlex.quote(docker_config)} " if docker_config else ""
for app in apps:
compose_dir = f"{current_path}/apps/{app}"
connection.run(
f"cd {shlex.quote(compose_dir)} && {pull_env}docker compose pull && docker compose up -d --remove-orphans"
)
finally:
if docker_config:
connection.run(f"rm -rf {shlex.quote(docker_config)}", hide=True)

prune_releases(connection, releases_path, keep_releases)
connection.run("docker container prune -f && docker image prune -a -f")
Expand Down
118 changes: 116 additions & 2 deletions .github/actions/deploy/tests/test_deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -367,12 +367,17 @@ def __init__(self, host, previous_manifest=""):
self.client = SimpleNamespace(set_missing_host_key_policy=lambda policy: None)
self.commands = []
self.uploads = []
self.stdins = []
self.previous_manifest = previous_manifest

def run(self, command, hide=False):
def run(self, command, hide=False, in_stream=None):
self.commands.append(command)
if in_stream is not None:
self.stdins.append(in_stream.read())
if command == "echo $HOME":
return SimpleNamespace(stdout="/home/deploy\n")
if command == "mktemp -d":
return SimpleNamespace(stdout="/tmp/fakeconfig\n")
if command.startswith("ls -1dt"):
releases = "\n".join(f"/home/deploy/flightdeck/releases/rel{i}/" for i in range(7))
return SimpleNamespace(stdout=releases + "\n")
Expand All @@ -384,6 +389,45 @@ def put(self, local, remote):
self.uploads.append((local, remote))


class LoginRegistryTest(unittest.TestCase):
def test_does_nothing_without_a_token(self):
fake = FakeConnection("deploy@host")
with patch.dict(os.environ, {}, clear=True):
result = deploy.login_registry(fake)

self.assertEqual(fake.commands, [])
self.assertIsNone(result)

def test_logs_in_with_the_token_over_stdin_not_the_command_line_scoped_to_a_temp_docker_config(self):
fake = FakeConnection("deploy@host")
with patch.dict(os.environ, {"GH_TOKEN": "ghs_secret123", "GITHUB_ACTOR": "flightdeck-bot"}, clear=True):
result = deploy.login_registry(fake)

self.assertEqual(
fake.commands,
[
"mktemp -d",
"DOCKER_CONFIG=/tmp/fakeconfig docker login ghcr.io -u flightdeck-bot --password-stdin",
],
)
self.assertNotIn("ghs_secret123", fake.commands[1])
self.assertEqual(fake.stdins, ["ghs_secret123"])
self.assertEqual(result, "/tmp/fakeconfig")

def test_defaults_actor_when_github_actor_is_unset(self):
fake = FakeConnection("deploy@host")
with patch.dict(os.environ, {"GH_TOKEN": "ghs_secret123"}, clear=True):
deploy.login_registry(fake)

self.assertEqual(
fake.commands,
[
"mktemp -d",
"DOCKER_CONFIG=/tmp/fakeconfig docker login ghcr.io -u flightdeck --password-stdin",
],
)


class DeployToHostTest(unittest.TestCase):
def test_full_sequence(self):
with tempfile.TemporaryDirectory() as directory:
Expand All @@ -393,7 +437,10 @@ def test_full_sequence(self):
config = {"hosts": ["deploy@host"], "keep_releases": 5}

fake = FakeConnection("deploy@host")
with patch.object(deploy, "Connection", return_value=fake):
with (
patch.object(deploy, "Connection", return_value=fake),
patch.dict(os.environ, {"GH_TOKEN": ""}),
):
deploy.deploy_to_host(
"deploy@host",
archive_path,
Expand All @@ -408,6 +455,7 @@ def test_full_sequence(self):
self.assertEqual(len(fake.uploads), 1)

joined = "\n".join(fake.commands)
self.assertNotIn("docker login", joined)
self.assertIn("docker network create traefik", joined)
self.assertIn("docker network create databases", joined)
self.assertIn("docker network create mcp", joined)
Expand All @@ -429,6 +477,72 @@ def test_full_sequence(self):
for kept in ("rel0", "rel1", "rel2", "rel3", "rel4"):
self.assertNotIn(kept, prune_command)

def test_logs_into_ghcr_before_pushing_the_release_when_a_token_is_available(self):
with tempfile.TemporaryDirectory() as directory:
work_dir = Path(directory)
archive_path = work_dir / "release.tar.gz"
archive_path.write_text("fake archive\n")
config = {"hosts": ["deploy@host"], "keep_releases": 5}

fake = FakeConnection("deploy@host")
with (
patch.object(deploy, "Connection", return_value=fake),
patch.dict(os.environ, {"GH_TOKEN": "ghs_secret123", "GITHUB_ACTOR": "flightdeck-bot"}),
):
deploy.deploy_to_host(
"deploy@host",
archive_path,
apps=["traefik"],
networks=["traefik"],
config=config,
release_name="20260824T211714Z",
)

login_index = fake.commands.index(
"DOCKER_CONFIG=/tmp/fakeconfig docker login ghcr.io -u flightdeck-bot --password-stdin"
)
push_index = next(i for i, command in enumerate(fake.commands) if command.startswith("tar -xzf"))
pull_index = next(i for i, command in enumerate(fake.commands) if "docker compose pull" in command)
cleanup_index = fake.commands.index("rm -rf /tmp/fakeconfig")
self.assertLess(login_index, push_index)
self.assertIn("DOCKER_CONFIG=/tmp/fakeconfig docker compose pull", fake.commands[pull_index])
self.assertGreater(cleanup_index, pull_index)
self.assertEqual(fake.stdins, ["ghs_secret123"])

def test_cleans_up_the_temporary_docker_config_even_if_pull_fails(self):
with tempfile.TemporaryDirectory() as directory:
work_dir = Path(directory)
archive_path = work_dir / "release.tar.gz"
archive_path.write_text("fake archive\n")
config = {"hosts": ["deploy@host"], "keep_releases": 5}

fake = FakeConnection("deploy@host")
original_run = fake.run

def failing_run(command, hide=False, in_stream=None):
result = original_run(command, hide=hide, in_stream=in_stream)
if "docker compose pull" in command:
raise RuntimeError("pull failed")
return result

fake.run = failing_run

with (
patch.object(deploy, "Connection", return_value=fake),
patch.dict(os.environ, {"GH_TOKEN": "ghs_secret123", "GITHUB_ACTOR": "flightdeck-bot"}),
self.assertRaises(RuntimeError),
):
deploy.deploy_to_host(
"deploy@host",
archive_path,
apps=["traefik"],
networks=["traefik"],
config=config,
release_name="20260824T211714Z",
)

self.assertIn("rm -rf /tmp/fakeconfig", fake.commands)

def test_stops_apps_removed_from_the_desired_set(self):
with tempfile.TemporaryDirectory() as directory:
work_dir = Path(directory)
Expand Down
3 changes: 3 additions & 0 deletions .github/actions/renovate/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ jobs:
apps: ${{ inputs.apps }} # required, JSON array e.g. '["traefik","rybbit"]'
target-manifest: ${{ matrix.manifest }} # required, path in this repository
ssh-private-key: ${{ secrets[matrix.ssh_private_key_secret] }}
# github-token: ${{ secrets.GITHUB_TOKEN }} # optional, default: unset (skip ghcr.io login)
# 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
- name: Notify Telegram
Expand All @@ -37,3 +38,5 @@ After every requested app has been pulled and recreated on a host, that host get
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.

`github-token` is optional, unlike in `deploy` - this action never resolves/downloads anything, so the only use for it is forwarding `GITHUB_TOKEN` to each host for a `docker login ghcr.io` before `docker compose pull`, letting a private GHCR image get re-pulled. Leave it unset if every renovated app's image is public. That login is scoped to a throwaway `DOCKER_CONFIG` directory, removed once the pull finishes, rather than the host's default `~/.docker/config.json` - see the main README's "Automated Deploy" section.
5 changes: 5 additions & 0 deletions .github/actions/renovate/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ inputs:
ssh-private-key:
description: SSH private key used to connect to the hosts.
required: true
github-token:
description: Token forwarded to each host to `docker login ghcr.io`, so a private GHCR image can be re-pulled. Leave unset to skip login (fine as long as every renovated app's image is public). The underlying package's own "Manage Actions access" setting must grant read to this workflow's repository, since GITHUB_TOKEN only carries that far.
required: false
default: ""
tailscale-oauth-client-id:
description: Tailscale OAuth client ID used to join the tailnet. Leave unset to skip joining a tailnet (e.g. when the runner already has network access to the hosts).
required: false
Expand Down Expand Up @@ -64,6 +68,7 @@ runs:
env:
APPS: ${{ inputs.apps }}
TARGET_MANIFEST: ${{ inputs.target-manifest }}
GH_TOKEN: ${{ inputs.github-token }}
run: |
jq -n --argjson apps "$APPS" --arg target_manifest "$TARGET_MANIFEST" \
'{apps: $apps, target_manifest: $target_manifest}' \
Expand Down
33 changes: 31 additions & 2 deletions .github/actions/renovate/renovate.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
contains the same data plus per-service image-version transitions as
structured JSON for downstream formatting.
"""
import io
import json
import os
import shlex
Expand All @@ -49,6 +50,28 @@ def expand_home(path, home):
return home + path[1:] if path.startswith("~") else path


def login_registry(connection):
"""Log into ghcr.io using the Actions-run GITHUB_TOKEN, so `docker
compose pull` can reach private GHCR images - same rationale as
deploy.py's own login_registry, including the throwaway
DOCKER_CONFIG directory instead of the host's default
~/.docker/config.json. Returns the directory to export as
DOCKER_CONFIG for the pull that follows (the caller removes it once
done), or None when no token is available."""
token = os.environ.get("GH_TOKEN")
if not token:
return None
docker_config = connection.run("mktemp -d", hide=True).stdout.strip()
actor = os.environ.get("GITHUB_ACTOR", "flightdeck")
connection.run(
f"DOCKER_CONFIG={shlex.quote(docker_config)} "
f"docker login ghcr.io -u {shlex.quote(actor)} --password-stdin",
hide=True,
in_stream=io.StringIO(token),
)
Comment on lines +66 to +71
return docker_config


def image_version(connection, image_id):
command = f"docker image inspect {shlex.quote(image_id)} --format '{{{{json .Config.Labels}}}}'"
labels = json.loads(connection.run(command, hide=True).stdout.strip() or "null") or {}
Expand Down Expand Up @@ -102,8 +125,14 @@ def renovate_host(host, base_path, app):
home = connection.run("echo $HOME", hide=True).stdout.strip()
compose_dir = shlex.quote(f"{expand_home(base_path, home)}/current/apps/{app}")
before = container_snapshot(connection, compose_dir)
connection.run(f"cd {compose_dir} && docker compose pull")
connection.run(f"cd {compose_dir} && docker compose up -d --remove-orphans")
docker_config = login_registry(connection)
try:
pull_env = f"DOCKER_CONFIG={shlex.quote(docker_config)} " if docker_config else ""
connection.run(f"cd {compose_dir} && {pull_env}docker compose pull")
connection.run(f"cd {compose_dir} && docker compose up -d --remove-orphans")
finally:
if docker_config:
connection.run(f"rm -rf {shlex.quote(docker_config)}", hide=True)
after = container_snapshot(connection, compose_dir)
return image_transitions(before, after)

Expand Down
Loading
Loading