From 08d17163ff03d5dd4b12ce6a8e4afc3219b995c9 Mon Sep 17 00:00:00 2001 From: Yuriy Kirillov Date: Sun, 11 Oct 2026 23:42:14 +0200 Subject: [PATCH 1/2] fix(deploy,renovate): forward GITHUB_TOKEN to ghcr.io login on the host docker compose pull fails with unauthorized on a private ghcr.io image because the target host's Docker daemon is never authenticated for it. Forward the Actions run's own GITHUB_TOKEN over the existing SSH connection and docker login ghcr.io before pulling, instead of minting and storing a separate long-lived registry credential as a new GitHub Secret. Fixes #230 --- .github/actions/deploy/README.md | 2 + .github/actions/deploy/action.yml | 2 +- .github/actions/deploy/deploy.py | 21 +++++++ .github/actions/deploy/tests/test_deploy.py | 62 ++++++++++++++++++- .github/actions/renovate/README.md | 3 + .github/actions/renovate/action.yml | 5 ++ .github/actions/renovate/renovate.py | 17 +++++ .../actions/renovate/tests/test_renovate.py | 49 ++++++++++++++- .github/workflows/renovate.yml | 1 + AGENTS.md | 2 +- README.md | 7 ++- 11 files changed, 164 insertions(+), 7 deletions(-) diff --git a/.github/actions/deploy/README.md b/.github/actions/deploy/README.md index d2713a3..c83583e 100644 --- a/.github/actions/deploy/README.md +++ b/.github/actions/deploy/README.md @@ -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. See the main README's "Automated Deploy" section and [#230](https://github.com/rubykatzen/flightdeck/issues/230). + 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 fb8a033..198fba4 100644 --- a/.github/actions/deploy/action.yml +++ b/.github/actions/deploy/action.yml @@ -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). diff --git a/.github/actions/deploy/deploy.py b/.github/actions/deploy/deploy.py index f3c7343..88fe29f 100644 --- a/.github/actions/deploy/deploy.py +++ b/.github/actions/deploy/deploy.py @@ -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 @@ -140,6 +141,25 @@ 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 the host's Docker daemon 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. A + no-op when GH_TOKEN isn't set (e.g. running deploy.py outside Actions). + See #230.""" + token = os.environ.get("GH_TOKEN") + if not token: + return + actor = os.environ.get("GITHUB_ACTOR", "flightdeck") + connection.run( + f"docker login ghcr.io -u {shlex.quote(actor)} --password-stdin", + hide=True, + in_stream=io.StringIO(token), + ) + + 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") @@ -180,6 +200,7 @@ 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) + login_registry(connection) push_release(connection, archive_path, release_path) for app in apps: data_dir = f"{base_path}/apps-data/{app}" diff --git a/.github/actions/deploy/tests/test_deploy.py b/.github/actions/deploy/tests/test_deploy.py index d32eda3..d4cbece 100644 --- a/.github/actions/deploy/tests/test_deploy.py +++ b/.github/actions/deploy/tests/test_deploy.py @@ -367,10 +367,13 @@ 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.startswith("ls -1dt"): @@ -384,6 +387,31 @@ 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): + deploy.login_registry(fake) + + self.assertEqual(fake.commands, []) + + def test_logs_in_with_the_token_over_stdin_not_the_command_line(self): + fake = FakeConnection("deploy@host") + with patch.dict(os.environ, {"GH_TOKEN": "ghs_secret123", "GITHUB_ACTOR": "flightdeck-bot"}, clear=True): + deploy.login_registry(fake) + + self.assertEqual(fake.commands, ["docker login ghcr.io -u flightdeck-bot --password-stdin"]) + self.assertNotIn("ghs_secret123", fake.commands[0]) + self.assertEqual(fake.stdins, ["ghs_secret123"]) + + 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, ["docker login ghcr.io -u flightdeck --password-stdin"]) + + class DeployToHostTest(unittest.TestCase): def test_full_sequence(self): with tempfile.TemporaryDirectory() as directory: @@ -393,7 +421,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, @@ -408,6 +439,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) @@ -429,6 +461,32 @@ 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 login ghcr.io -u flightdeck-bot --password-stdin") + push_index = next(i for i, command in enumerate(fake.commands) if command.startswith("tar -xzf")) + self.assertLess(login_index, push_index) + self.assertEqual(fake.stdins, ["ghs_secret123"]) + def test_stops_apps_removed_from_the_desired_set(self): with tempfile.TemporaryDirectory() as directory: work_dir = Path(directory) diff --git a/.github/actions/renovate/README.md b/.github/actions/renovate/README.md index eb05d8b..7708582 100644 --- a/.github/actions/renovate/README.md +++ b/.github/actions/renovate/README.md @@ -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 @@ -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. See the main README's "Automated Deploy" section and [#230](https://github.com/rubykatzen/flightdeck/issues/230). diff --git a/.github/actions/renovate/action.yml b/.github/actions/renovate/action.yml index d906f18..eb82c25 100644 --- a/.github/actions/renovate/action.yml +++ b/.github/actions/renovate/action.yml @@ -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 @@ -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}' \ diff --git a/.github/actions/renovate/renovate.py b/.github/actions/renovate/renovate.py index e9e4756..7a4eb7e 100644 --- a/.github/actions/renovate/renovate.py +++ b/.github/actions/renovate/renovate.py @@ -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 @@ -49,6 +50,21 @@ def expand_home(path, home): return home + path[1:] if path.startswith("~") else path +def login_registry(connection): + """Log the host's Docker daemon 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, see #230.""" + token = os.environ.get("GH_TOKEN") + if not token: + return + actor = os.environ.get("GITHUB_ACTOR", "flightdeck") + connection.run( + f"docker login ghcr.io -u {shlex.quote(actor)} --password-stdin", + hide=True, + in_stream=io.StringIO(token), + ) + + 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 {} @@ -102,6 +118,7 @@ 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) + login_registry(connection) connection.run(f"cd {compose_dir} && docker compose pull") connection.run(f"cd {compose_dir} && docker compose up -d --remove-orphans") after = container_snapshot(connection, compose_dir) diff --git a/.github/actions/renovate/tests/test_renovate.py b/.github/actions/renovate/tests/test_renovate.py index 904ed49..3cbc602 100644 --- a/.github/actions/renovate/tests/test_renovate.py +++ b/.github/actions/renovate/tests/test_renovate.py @@ -26,12 +26,15 @@ def __init__(self, host, snapshots=None, labels=None): self.host = host self.client = SimpleNamespace(set_missing_host_key_policy=lambda policy: None) self.commands = [] + self.stdins = [] self.snapshots = list(snapshots or [{}, {}]) self.current_snapshot = {} self.labels = labels or {} - 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.endswith("docker compose ps --all -q"): @@ -67,18 +70,60 @@ def test_leaves_absolute_path_untouched(self): self.assertEqual(renovate.expand_home("/opt/flightdeck", "/home/deploy"), "/opt/flightdeck") +class LoginRegistryTest(unittest.TestCase): + def test_does_nothing_without_a_token(self): + fake = FakeConnection("deploy@host") + with patch.dict(os.environ, {}, clear=True): + renovate.login_registry(fake) + + self.assertEqual(fake.commands, []) + + def test_logs_in_with_the_token_over_stdin_not_the_command_line(self): + fake = FakeConnection("deploy@host") + with patch.dict(os.environ, {"GH_TOKEN": "ghs_secret123", "GITHUB_ACTOR": "flightdeck-bot"}, clear=True): + renovate.login_registry(fake) + + self.assertEqual(fake.commands, ["docker login ghcr.io -u flightdeck-bot --password-stdin"]) + self.assertNotIn("ghs_secret123", fake.commands[0]) + self.assertEqual(fake.stdins, ["ghs_secret123"]) + + 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): + renovate.login_registry(fake) + + self.assertEqual(fake.commands, ["docker login ghcr.io -u flightdeck --password-stdin"]) + + class RenovateHostTest(unittest.TestCase): def test_pulls_and_recreates_without_touching_the_release(self): fake = FakeConnection("deploy@host") - with patch.object(renovate, "Connection", return_value=fake): + with ( + patch.object(renovate, "Connection", return_value=fake), + patch.dict(os.environ, {"GH_TOKEN": ""}), + ): renovate.renovate_host("deploy@host", "~/flightdeck", "beszel") joined = "\n".join(fake.commands) + self.assertNotIn("docker login", joined) self.assertIn("cd /home/deploy/flightdeck/current/apps/beszel", joined) self.assertIn("docker compose pull", joined) self.assertIn("docker compose up -d --remove-orphans", joined) self.assertEqual(joined.count("docker compose ps --all -q"), 2) + def test_logs_into_ghcr_before_pulling_when_a_token_is_available(self): + fake = FakeConnection("deploy@host") + with ( + patch.object(renovate, "Connection", return_value=fake), + patch.dict(os.environ, {"GH_TOKEN": "ghs_secret123", "GITHUB_ACTOR": "flightdeck-bot"}), + ): + renovate.renovate_host("deploy@host", "~/flightdeck", "beszel") + + login_index = fake.commands.index("docker login ghcr.io -u flightdeck-bot --password-stdin") + pull_index = next(i for i, command in enumerate(fake.commands) if command.endswith("docker compose pull")) + self.assertLess(login_index, pull_index) + self.assertEqual(fake.stdins, ["ghs_secret123"]) + def test_returns_version_transition_when_the_image_changed(self): before = { "container-old": { diff --git a/.github/workflows/renovate.yml b/.github/workflows/renovate.yml index 263c8a8..02766ff 100644 --- a/.github/workflows/renovate.yml +++ b/.github/workflows/renovate.yml @@ -33,6 +33,7 @@ jobs: apps: ${{ inputs.apps || '[]' }} target-manifest: ${{ matrix.manifest }} ssh-private-key: ${{ secrets[matrix.ssh_private_key_secret] }} + github-token: ${{ secrets.GITHUB_TOKEN }} tailscale-oauth-client-id: ${{ vars.TAILSCALE_OAUTH_CLIENT_ID }} tailscale-oauth-secret: ${{ secrets.TAILSCALE_OAUTH_SECRET }} - name: Notify Telegram diff --git a/AGENTS.md b/AGENTS.md index 06e417d..9081ebf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -408,7 +408,7 @@ GitHub Actions workflow (`.github/workflows/release.yml`) manages releases via [ Deployment helpers live in this repository, entirely under `.github/actions/deploy/` (and `.github/actions/renovate/` for its narrower sibling), run only on the GitHub Actions runner - the target host never runs any of this: -- `.github/actions/deploy/deploy.py` is the deploy entrypoint. It resolves and downloads every ref in `app_refs` (the app bundles, at least one required — flightdeck's own `apps/` catalog is just another entry, not implicit) and merges them into a release tree locally; for each app in the target's `apps` mapping, downloads its `env_refs` (still encrypted), decrypts them with the target's private SOPS age key, writes the plaintext into that app's `.env` in the release tree, and renders that app's `*.tpl` files in place with the decrypted values (see "Config Templates" above). It writes a `manifest.json` into the release tree (`schema_version`, `release` timestamp, resolved `app_refs`/`env_refs` - the actual tag `resolve.py` pulled, never `@latest` - and the desired `apps` list; no secrets, no target identifier, since whoever's reading it is already on that specific host). It then opens an SSH connection per host, pushes the finished release as one tarball (real `.env`, already-rendered config, the manifest, all versioned together), bootstraps networks/directories idempotently, reads the *previous* release's `manifest.json` off `current` and `docker compose down`s any app present there but no longer in the desired set (using `current` before it moves, so that app's last-known compose file/`.env` are still intact - safe even though every release tree already contains every app's compose file regardless of whether the target wants it, since `build_release` copies the whole catalog every time), switches `current` to the new timestamped release, and runs `docker compose pull && docker compose up -d` per desired app directly (no wrapper script on the host at all). +- `.github/actions/deploy/deploy.py` is the deploy entrypoint. It resolves and downloads every ref in `app_refs` (the app bundles, at least one required — flightdeck's own `apps/` catalog is just another entry, not implicit) and merges them into a release tree locally; for each app in the target's `apps` mapping, downloads its `env_refs` (still encrypted), decrypts them with the target's private SOPS age key, writes the plaintext into that app's `.env` in the release tree, and renders that app's `*.tpl` files in place with the decrypted values (see "Config Templates" above). It writes a `manifest.json` into the release tree (`schema_version`, `release` timestamp, resolved `app_refs`/`env_refs` - the actual tag `resolve.py` pulled, never `@latest` - and the desired `apps` list; no secrets, no target identifier, since whoever's reading it is already on that specific host). It then opens an SSH connection per host, pushes the finished release as one tarball (real `.env`, already-rendered config, the manifest, all versioned together), bootstraps networks/directories idempotently, reads the *previous* release's `manifest.json` off `current` and `docker compose down`s any app present there but no longer in the desired set (using `current` before it moves, so that app's last-known compose file/`.env` are still intact - safe even though every release tree already contains every app's compose file regardless of whether the target wants it, since `build_release` copies the whole catalog every time), switches `current` to the new timestamped release, and runs `docker compose pull && docker compose up -d` per desired app directly (no wrapper script on the host at all). Before any of that, it also logs the host's Docker daemon into `ghcr.io` using the Actions run's own `GITHUB_TOKEN`, forwarded over the same SSH connection via stdin (never as a command-line argument, never written to disk) - this is what lets `docker compose pull` reach 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; `GITHUB_TOKEN` only ever authenticates to `ghcr.io` itself, never a third-party registry, and the login is skipped entirely when no token is forwarded (e.g. every image in play is public). `.github/actions/renovate/renovate.py` does the same thing, keyed off the same `GH_TOKEN` env var, before its own `docker compose pull`. See #230. - `.github/actions/deploy/resolve.py`, `.github/actions/deploy/collisions.py`, `.github/actions/deploy/vault.py`, and `.github/actions/deploy/render.py` hold, respectively, the ref-resolution, ciphertext collision-detection, decryption, and template-rendering logic - each with real `unittest` coverage in `.github/actions/deploy/tests/`. - `.github/actions/encrypt-env/` is a local composite action for rendering `vaults/` manifests from GitHub Secrets/Variables, encrypting them for age recipients, and publishing `.sops.env` as a GitHub Release asset — vault manifests hold only env/secrets, not app selection - `.github/actions/deploy/` is a composite action consumer repos call to run `deploy.py` from GitHub Actions over an optional Tailscale connection, without holding any deploy secrets in this repository. It doesn't check anything out itself - `target-manifest` is read from whatever the caller's own preceding `actions/checkout` step already put on disk, and `deploy.py` itself comes along automatically via `$GITHUB_ACTION_PATH`. `.github/actions/renovate/` is the same shape, scoped to `renovate.py` instead. diff --git a/README.md b/README.md index 8d15767..ab675f5 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,9 @@ The deploy is push-based and runs entirely on the GitHub Actions runner: 4. Decrypt each app's env with the target's private SOPS age key (a GitHub Secret) and write it straight into that app's `.env` in the release tree. 5. Render that app's `*.tpl` config files in place, next to its `docker-compose.yml`, using the decrypted values — the same substitution `envsubst` does, run here instead of on the host. 6. Write a `manifest.json` into the release tree — resolved `app_refs`/`env_refs` (the actual tag pulled, not `@latest`) and the desired app set, no secrets. -7. Push the finished release (real `.env`, already-rendered config, the manifest, one tarball) to each host over SSH. Before switching `current`, compare the new desired app set against the previous release's `manifest.json` and `docker compose down` anything no longer desired, then switch the `current` symlink and run `docker compose pull && docker compose up -d` per app. +7. Push the finished release (real `.env`, already-rendered config, the manifest, one tarball) to each host over SSH. Before switching `current`, log the host's Docker daemon into `ghcr.io` using the Actions run's own `GITHUB_TOKEN` (forwarded over the same SSH connection, never written to disk), compare the new desired app set against the previous release's `manifest.json` and `docker compose down` anything no longer desired, then switch the `current` symlink and run `docker compose pull && docker compose up -d` per app. + +The `ghcr.io` login (step 7) only ever covers `ghcr.io` itself, not arbitrary private registries — it rides on the Actions run's own `GITHUB_TOKEN`, which is scoped to that one workflow run and only ever authenticates to GHCR, never a third-party registry. This is enough to pull a private image published from a *different* repository under the same org/user (e.g. an app whose own repo publishes `ghcr.io//`, included in a target's `apps`), as long as that package's own **Package settings → Manage Actions access** grants read to this workflow's repository — `GITHUB_TOKEN` doesn't reach further than that by default. No separate registry credential ever needs to be minted, stored, or rotated as a GitHub Secret. See [#230](https://github.com/rubykatzen/flightdeck/issues/230). What gets deployed — which app bundles, which apps actually run, and which encrypted env sources feed each one — is configured declaratively per target; see "Vaults And Targets" below for the manifest format. @@ -397,6 +399,8 @@ 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. +`github-token` is optional here (unlike [`deploy`](#deploy), where it's required for resolving release refs) — `renovate.py` never downloads anything, so it only exists to forward `GITHUB_TOKEN` to the host for a `ghcr.io` login before `docker compose pull`, same rationale as `deploy`'s own login step above. Leave it unset if every renovated app's image is public. + 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. 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. @@ -417,6 +421,7 @@ jobs: apps: ${{ inputs.apps || '[]' }} # JSON array, e.g. '["traefik","rybbit"]'; '[]' on the nightly cron run target-manifest: ${{ matrix.manifest }} 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 }} # tailscale-oauth-secret: ${{ secrets.TAILSCALE_OAUTH_SECRET }} - name: Notify Telegram From c79f7ac75ea6f92e7c04afd0bb0975f3d7bd0737 Mon Sep 17 00:00:00 2001 From: Yuriy Kirillov Date: Mon, 12 Oct 2026 13:08:07 +0200 Subject: [PATCH 2/2] fix(deploy,renovate): scope ghcr.io login to a throwaway DOCKER_CONFIG Copilot review on #231 flagged that a plain `docker login` writes into the host's default ~/.docker/config.json, leaving the short-lived GITHUB_TOKEN on disk past the run and silently replacing whatever ghcr.io credential was already configured there. Point DOCKER_CONFIG at a throwaway directory for the login and the pull that follows instead, and remove it in a finally block once done, even if the pull fails. Also drops the #230 cross-references added in the previous commit, per request - the behavior is documented on its own terms instead. Co-Authored-By: Claude Sonnet 5 --- .github/actions/deploy/README.md | 2 +- .github/actions/deploy/deploy.py | 60 ++++++++++------ .github/actions/deploy/tests/test_deploy.py | 72 ++++++++++++++++--- .github/actions/renovate/README.md | 2 +- .github/actions/renovate/renovate.py | 26 +++++-- .../actions/renovate/tests/test_renovate.py | 58 ++++++++++++--- AGENTS.md | 2 +- README.md | 6 +- 8 files changed, 178 insertions(+), 50 deletions(-) diff --git a/.github/actions/deploy/README.md b/.github/actions/deploy/README.md index c83583e..7bf2352 100644 --- a/.github/actions/deploy/README.md +++ b/.github/actions/deploy/README.md @@ -35,6 +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. See the main README's "Automated Deploy" section and [#230](https://github.com/rubykatzen/flightdeck/issues/230). +`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. diff --git a/.github/actions/deploy/deploy.py b/.github/actions/deploy/deploy.py index 88fe29f..3dd9f14 100644 --- a/.github/actions/deploy/deploy.py +++ b/.github/actions/deploy/deploy.py @@ -142,22 +142,31 @@ def bootstrap_host(connection, base_path, networks): def login_registry(connection): - """Log the host's Docker daemon 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. A - no-op when GH_TOKEN isn't set (e.g. running deploy.py outside Actions). - See #230.""" + """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 + 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), ) + return docker_config def push_release(connection, archive_path, release_path): @@ -200,20 +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) - login_registry(connection) - 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") diff --git a/.github/actions/deploy/tests/test_deploy.py b/.github/actions/deploy/tests/test_deploy.py index d4cbece..8ee6db2 100644 --- a/.github/actions/deploy/tests/test_deploy.py +++ b/.github/actions/deploy/tests/test_deploy.py @@ -376,6 +376,8 @@ def run(self, command, hide=False, in_stream=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") @@ -391,25 +393,39 @@ class LoginRegistryTest(unittest.TestCase): def test_does_nothing_without_a_token(self): fake = FakeConnection("deploy@host") with patch.dict(os.environ, {}, clear=True): - deploy.login_registry(fake) + 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(self): + 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): - deploy.login_registry(fake) - - self.assertEqual(fake.commands, ["docker login ghcr.io -u flightdeck-bot --password-stdin"]) - self.assertNotIn("ghs_secret123", fake.commands[0]) + 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, ["docker login ghcr.io -u flightdeck --password-stdin"]) + self.assertEqual( + fake.commands, + [ + "mktemp -d", + "DOCKER_CONFIG=/tmp/fakeconfig docker login ghcr.io -u flightdeck --password-stdin", + ], + ) class DeployToHostTest(unittest.TestCase): @@ -482,11 +498,51 @@ def test_logs_into_ghcr_before_pushing_the_release_when_a_token_is_available(sel release_name="20260824T211714Z", ) - login_index = fake.commands.index("docker login ghcr.io -u flightdeck-bot --password-stdin") + 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) diff --git a/.github/actions/renovate/README.md b/.github/actions/renovate/README.md index 7708582..bb281b7 100644 --- a/.github/actions/renovate/README.md +++ b/.github/actions/renovate/README.md @@ -39,4 +39,4 @@ The action prepares a host-grouped message as `telegram-message` only when somet 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. See the main README's "Automated Deploy" section and [#230](https://github.com/rubykatzen/flightdeck/issues/230). +`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. diff --git a/.github/actions/renovate/renovate.py b/.github/actions/renovate/renovate.py index 7a4eb7e..6340dbc 100644 --- a/.github/actions/renovate/renovate.py +++ b/.github/actions/renovate/renovate.py @@ -51,18 +51,25 @@ def expand_home(path, home): def login_registry(connection): - """Log the host's Docker daemon 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, see #230.""" + """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 + 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), ) + return docker_config def image_version(connection, image_id): @@ -118,9 +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) - login_registry(connection) - 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) diff --git a/.github/actions/renovate/tests/test_renovate.py b/.github/actions/renovate/tests/test_renovate.py index 3cbc602..b291e35 100644 --- a/.github/actions/renovate/tests/test_renovate.py +++ b/.github/actions/renovate/tests/test_renovate.py @@ -37,6 +37,8 @@ def run(self, command, hide=False, in_stream=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.endswith("docker compose ps --all -q"): self.current_snapshot = self.snapshots.pop(0) return SimpleNamespace(stdout="\n".join(self.current_snapshot)) @@ -74,25 +76,39 @@ class LoginRegistryTest(unittest.TestCase): def test_does_nothing_without_a_token(self): fake = FakeConnection("deploy@host") with patch.dict(os.environ, {}, clear=True): - renovate.login_registry(fake) + result = renovate.login_registry(fake) self.assertEqual(fake.commands, []) + self.assertIsNone(result) - def test_logs_in_with_the_token_over_stdin_not_the_command_line(self): + 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): - renovate.login_registry(fake) + result = renovate.login_registry(fake) - self.assertEqual(fake.commands, ["docker login ghcr.io -u flightdeck-bot --password-stdin"]) - self.assertNotIn("ghs_secret123", fake.commands[0]) + 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): renovate.login_registry(fake) - self.assertEqual(fake.commands, ["docker login ghcr.io -u flightdeck --password-stdin"]) + self.assertEqual( + fake.commands, + [ + "mktemp -d", + "DOCKER_CONFIG=/tmp/fakeconfig docker login ghcr.io -u flightdeck --password-stdin", + ], + ) class RenovateHostTest(unittest.TestCase): @@ -119,11 +135,37 @@ def test_logs_into_ghcr_before_pulling_when_a_token_is_available(self): ): renovate.renovate_host("deploy@host", "~/flightdeck", "beszel") - login_index = fake.commands.index("docker login ghcr.io -u flightdeck-bot --password-stdin") - pull_index = next(i for i, command in enumerate(fake.commands) if command.endswith("docker compose pull")) + login_index = fake.commands.index( + "DOCKER_CONFIG=/tmp/fakeconfig docker login ghcr.io -u flightdeck-bot --password-stdin" + ) + 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, pull_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): + 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(renovate, "Connection", return_value=fake), + patch.dict(os.environ, {"GH_TOKEN": "ghs_secret123", "GITHUB_ACTOR": "flightdeck-bot"}), + self.assertRaises(RuntimeError), + ): + renovate.renovate_host("deploy@host", "~/flightdeck", "beszel") + + self.assertIn("rm -rf /tmp/fakeconfig", fake.commands) + def test_returns_version_transition_when_the_image_changed(self): before = { "container-old": { diff --git a/AGENTS.md b/AGENTS.md index 9081ebf..a6c82a2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -408,7 +408,7 @@ GitHub Actions workflow (`.github/workflows/release.yml`) manages releases via [ Deployment helpers live in this repository, entirely under `.github/actions/deploy/` (and `.github/actions/renovate/` for its narrower sibling), run only on the GitHub Actions runner - the target host never runs any of this: -- `.github/actions/deploy/deploy.py` is the deploy entrypoint. It resolves and downloads every ref in `app_refs` (the app bundles, at least one required — flightdeck's own `apps/` catalog is just another entry, not implicit) and merges them into a release tree locally; for each app in the target's `apps` mapping, downloads its `env_refs` (still encrypted), decrypts them with the target's private SOPS age key, writes the plaintext into that app's `.env` in the release tree, and renders that app's `*.tpl` files in place with the decrypted values (see "Config Templates" above). It writes a `manifest.json` into the release tree (`schema_version`, `release` timestamp, resolved `app_refs`/`env_refs` - the actual tag `resolve.py` pulled, never `@latest` - and the desired `apps` list; no secrets, no target identifier, since whoever's reading it is already on that specific host). It then opens an SSH connection per host, pushes the finished release as one tarball (real `.env`, already-rendered config, the manifest, all versioned together), bootstraps networks/directories idempotently, reads the *previous* release's `manifest.json` off `current` and `docker compose down`s any app present there but no longer in the desired set (using `current` before it moves, so that app's last-known compose file/`.env` are still intact - safe even though every release tree already contains every app's compose file regardless of whether the target wants it, since `build_release` copies the whole catalog every time), switches `current` to the new timestamped release, and runs `docker compose pull && docker compose up -d` per desired app directly (no wrapper script on the host at all). Before any of that, it also logs the host's Docker daemon into `ghcr.io` using the Actions run's own `GITHUB_TOKEN`, forwarded over the same SSH connection via stdin (never as a command-line argument, never written to disk) - this is what lets `docker compose pull` reach 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; `GITHUB_TOKEN` only ever authenticates to `ghcr.io` itself, never a third-party registry, and the login is skipped entirely when no token is forwarded (e.g. every image in play is public). `.github/actions/renovate/renovate.py` does the same thing, keyed off the same `GH_TOKEN` env var, before its own `docker compose pull`. See #230. +- `.github/actions/deploy/deploy.py` is the deploy entrypoint. It resolves and downloads every ref in `app_refs` (the app bundles, at least one required — flightdeck's own `apps/` catalog is just another entry, not implicit) and merges them into a release tree locally; for each app in the target's `apps` mapping, downloads its `env_refs` (still encrypted), decrypts them with the target's private SOPS age key, writes the plaintext into that app's `.env` in the release tree, and renders that app's `*.tpl` files in place with the decrypted values (see "Config Templates" above). It writes a `manifest.json` into the release tree (`schema_version`, `release` timestamp, resolved `app_refs`/`env_refs` - the actual tag `resolve.py` pulled, never `@latest` - and the desired `apps` list; no secrets, no target identifier, since whoever's reading it is already on that specific host). It then opens an SSH connection per host, pushes the finished release as one tarball (real `.env`, already-rendered config, the manifest, all versioned together), bootstraps networks/directories idempotently, reads the *previous* release's `manifest.json` off `current` and `docker compose down`s any app present there but no longer in the desired set (using `current` before it moves, so that app's last-known compose file/`.env` are still intact - safe even though every release tree already contains every app's compose file regardless of whether the target wants it, since `build_release` copies the whole catalog every time), switches `current` to the new timestamped release, and runs `docker compose pull && docker compose up -d` per desired app directly (no wrapper script on the host at all). Before any of that, it also logs into `ghcr.io` using the Actions run's own `GITHUB_TOKEN`, forwarded over the same SSH connection via stdin (never as a command-line argument) - this is what lets `docker compose pull` reach 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; `GITHUB_TOKEN` only ever authenticates to `ghcr.io` itself, never a third-party registry, and the login is skipped entirely when no token is forwarded (e.g. every image in play is public). The login targets a throwaway `DOCKER_CONFIG` directory (`mktemp -d` on the host) instead of the host's default `~/.docker/config.json` - a plain `docker login` would leave the short-lived token on disk past this run and silently replace whatever `ghcr.io` credential was already configured there - and that directory is removed in a `finally` block once the pulls finish, even if one of them fails. `.github/actions/renovate/renovate.py` does the same thing, keyed off the same `GH_TOKEN` env var, before its own `docker compose pull`. - `.github/actions/deploy/resolve.py`, `.github/actions/deploy/collisions.py`, `.github/actions/deploy/vault.py`, and `.github/actions/deploy/render.py` hold, respectively, the ref-resolution, ciphertext collision-detection, decryption, and template-rendering logic - each with real `unittest` coverage in `.github/actions/deploy/tests/`. - `.github/actions/encrypt-env/` is a local composite action for rendering `vaults/` manifests from GitHub Secrets/Variables, encrypting them for age recipients, and publishing `.sops.env` as a GitHub Release asset — vault manifests hold only env/secrets, not app selection - `.github/actions/deploy/` is a composite action consumer repos call to run `deploy.py` from GitHub Actions over an optional Tailscale connection, without holding any deploy secrets in this repository. It doesn't check anything out itself - `target-manifest` is read from whatever the caller's own preceding `actions/checkout` step already put on disk, and `deploy.py` itself comes along automatically via `$GITHUB_ACTION_PATH`. `.github/actions/renovate/` is the same shape, scoped to `renovate.py` instead. diff --git a/README.md b/README.md index ab675f5..fab1dc1 100644 --- a/README.md +++ b/README.md @@ -32,9 +32,11 @@ The deploy is push-based and runs entirely on the GitHub Actions runner: 4. Decrypt each app's env with the target's private SOPS age key (a GitHub Secret) and write it straight into that app's `.env` in the release tree. 5. Render that app's `*.tpl` config files in place, next to its `docker-compose.yml`, using the decrypted values — the same substitution `envsubst` does, run here instead of on the host. 6. Write a `manifest.json` into the release tree — resolved `app_refs`/`env_refs` (the actual tag pulled, not `@latest`) and the desired app set, no secrets. -7. Push the finished release (real `.env`, already-rendered config, the manifest, one tarball) to each host over SSH. Before switching `current`, log the host's Docker daemon into `ghcr.io` using the Actions run's own `GITHUB_TOKEN` (forwarded over the same SSH connection, never written to disk), compare the new desired app set against the previous release's `manifest.json` and `docker compose down` anything no longer desired, then switch the `current` symlink and run `docker compose pull && docker compose up -d` per app. +7. Push the finished release (real `.env`, already-rendered config, the manifest, one tarball) to each host over SSH. Before switching `current`, log into `ghcr.io` using the Actions run's own `GITHUB_TOKEN` (forwarded over the same SSH connection via stdin, scoped to a throwaway `DOCKER_CONFIG` directory removed once the pulls finish, rather than the host's default `~/.docker/config.json` - see below), compare the new desired app set against the previous release's `manifest.json` and `docker compose down` anything no longer desired, then switch the `current` symlink and run `docker compose pull && docker compose up -d` per app. -The `ghcr.io` login (step 7) only ever covers `ghcr.io` itself, not arbitrary private registries — it rides on the Actions run's own `GITHUB_TOKEN`, which is scoped to that one workflow run and only ever authenticates to GHCR, never a third-party registry. This is enough to pull a private image published from a *different* repository under the same org/user (e.g. an app whose own repo publishes `ghcr.io//`, included in a target's `apps`), as long as that package's own **Package settings → Manage Actions access** grants read to this workflow's repository — `GITHUB_TOKEN` doesn't reach further than that by default. No separate registry credential ever needs to be minted, stored, or rotated as a GitHub Secret. See [#230](https://github.com/rubykatzen/flightdeck/issues/230). +The `ghcr.io` login (step 7) only ever covers `ghcr.io` itself, not arbitrary private registries — it rides on the Actions run's own `GITHUB_TOKEN`, which is scoped to that one workflow run and only ever authenticates to GHCR, never a third-party registry. This is enough to pull a private image published from a *different* repository under the same org/user (e.g. an app whose own repo publishes `ghcr.io//`, included in a target's `apps`), as long as that package's own **Package settings → Manage Actions access** grants read to this workflow's repository — `GITHUB_TOKEN` doesn't reach further than that by default. No separate registry credential ever needs to be minted, stored, or rotated as a GitHub Secret. + +A plain `docker login` writes into the host's default `~/.docker/config.json`, which would leave the short-lived `GITHUB_TOKEN` sitting on disk after the run ends and silently replace whatever `ghcr.io` credential was already configured there. Both `deploy.py` and `renovate.py` avoid that by creating a throwaway directory (`mktemp -d` on the host) and pointing `DOCKER_CONFIG` at it for the login and the pull that follows, then removing it afterward - in a `finally` block, so it's cleaned up even if the pull itself fails. The host's own default Docker config is never touched. What gets deployed — which app bundles, which apps actually run, and which encrypted env sources feed each one — is configured declaratively per target; see "Vaults And Targets" below for the manifest format.