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
232 changes: 162 additions & 70 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,34 @@
# merged PR would be arbitrary execution on that machine. Neither of the two
# needs anything from the host — only Node/pnpm and the run's own GITHUB_TOKEN
# to publish to GHCR.
# - deploy runs self-hosted because its job *is* to act on the target host, and
# for that very reason it never checks out or runs repository code.
# - deploy ALSO runs on GitHub's ephemeral runners (`ubuntu-latest`), not
# self-hosted. This repo (`soy-chrislo/queres`) is a personal-account repo, not a
# member of any GitHub organization — verified via
# `gh api orgs/tunodo-organization/actions/runner-groups` against the org that
# hosts this project's private twin. Org-scoped runner-groups are the only
# mechanism GitHub offers to safely expose a self-hosted runner to a public repo,
# and they don't apply outside org membership: a self-hosted `deploy` job here
# would never be picked up, ever — not unconfigured, structurally impossible for
# a personal-account repo. See ADR-0027 for the full reasoning.
#
# The job still doesn't check out or run repository code — same rationale as
# before: whoever controls the target host's Docker socket controls the host,
# and this job needs that socket, so no repository code should ever execute
# with that privilege. Instead it reaches the VPS over SSH as an existing
# dedicated deploy system user, the same convention several other, unrelated
# projects already deployed from this same VPS use to deploy from GitHub-hosted
# runners (real hostname/username and the other projects' key identifiers are
# deliberately not spelled out here — this is a public repo and that account is
# shared with other tenants' infrastructure; see deploy/README.md and the
# maintainer's private notes). This reverses this file's original "avoids
# adding another pair of keys to rotate" rationale for `deploy`: that rationale
# assumed the runner already lived on the host, which is no longer true for
# this repo — the SSH key it was avoiding is now the only viable mechanism, not
# a preference. The deploy user's `docker` group membership is already
# root-equivalent on this shared VPS (whoever can reach the socket can mount
# any host path or run a privileged container); this SSH key inherits that same
# risk, unchanged from what the other projects sharing that account already
# accept.
#
# Triggered on every push to staging or main, or manually (workflow_dispatch) against
# those same two branches. No other branch or tag is a valid target (see the step
Expand Down Expand Up @@ -197,23 +223,25 @@ jobs:

deploy:
name: Deploy en VPS
# The only job that runs in the self-hosted pool, and by design: its job is to act
# on the target host, so it needs its filesystem and its Docker socket.
#
# There's no SSH/SCP involved because the runner already runs on the target machine:
# a network hop back to itself would be indirection with no benefit, and it would
# add another pair of keys to keep and rotate.
# Runs on GitHub's ephemeral runners (`ubuntu-latest`), same as the other two
# jobs — see the "Runner assignment" header comment for why self-hosted is
# structurally unavailable for this repo. It reaches the VPS over SSH as a
# dedicated deploy account instead of running directly on the target host.
#
# This job does NOT check out the repo and must not acquire it: no repository code
# runs here. The reason is security, not style — whoever controls the Docker socket
# controls the host: they can mount any filesystem path inside a container and spin
# up privileged containers, so socket access is equivalent to root on the machine.
# Running repository code in a job with that access turns any merged change into
# arbitrary execution at that privilege level. That's why this job only consumes two
# artifacts already produced on ephemeral runners — the frontends bundle and the
# image published to GHCR — and applies them. If it ever seems to need something
# from the repo, reconsider the job's design before adding an `actions/checkout`.
runs-on: [self-hosted, linux, deploy-host]
# This job still does NOT check out the repo and must not acquire it: no
# repository code runs here, on this runner or on the VPS. The reason is
# security, not style — whoever controls the target host's Docker socket
# controls the host: they can mount any filesystem path inside a container and
# spin up privileged containers, so socket access is equivalent to root on the
# machine. Running repository code with that access (directly, or indirectly by
# having it dictate what the SSH session executes) turns any merged change into
# arbitrary execution at that privilege level. That's why this job only
# consumes two artifacts already produced on ephemeral runners — the frontends
# bundle and the image published to GHCR — and applies them over SSH using a
# remote script authored here, in the workflow file, never fetched from repo
# content at deploy time. If it ever seems to need something from the repo,
# reconsider the job's design before adding an `actions/checkout`.
runs-on: ubuntu-latest
needs: [build-and-push, build-frontends]
# Hook point for GitHub Environments: `env_name` already resolves to exactly
# `prod` or `staging` (see the step "Determinar ambiente desde rama"). By itself it
Expand All @@ -223,43 +251,73 @@ jobs:
# they're configured.
environment: ${{ needs.build-and-push.outputs.env_name }}
permissions:
# No `contents: read`: this job doesn't check out or read the repo via API. The
# download of this run's own artifacts uses the runner's runtime token, and the
# GHCR login uses the GITHUB_TOKEN with the only scope declared here.
# No `contents: read`: this job doesn't check out or read the repo via API. It
# doesn't need `packages: read` either anymore — the `docker compose pull` below
# is unauthenticated, because ghcr.io/soy-chrislo/queres is a public package
# (ADR-0027), so there's no GHCR login left in this job at all. The declaration
# is left in place as a no-op rather than removed; tightening it further isn't
# part of this change's scope.
packages: read

steps:
- name: Limpiar artefactos de corridas previas
# A self-hosted runner's workspace persists between runs. Without this rm, the
# download below would mix with files from a previous deploy, and old assets
# could get copied into the served directory.
run: |
set -euo pipefail
# `set -u` aborts if GITHUB_WORKSPACE weren't *defined*, but not if it were
# defined and empty: in that case the `rm -rf` below would degenerate into
# `rm -rf /frontends`. Same explicit guard as IMAGE_TAG/DEPLOY_PATH in the
# deploy step.
: "${GITHUB_WORKSPACE:?GITHUB_WORKSPACE no debe estar vacio}"
rm -rf "$GITHUB_WORKSPACE/frontends"

- name: Descargar artefactos de frontends
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: frontends
path: ./frontends

- name: Login en GHCR
# Uses the run's own ephemeral GITHUB_TOKEN (same mechanism as build-and-push)
# instead of a long-lived credential stored on the host: it lives only as long
# as the job, its scope is the one declared above (packages: read), and it
# doesn't depend on the personal environment of the user running the runner.
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Configurar acceso SSH a la VPS
# Host key pinned from a secret captured once via `ssh-keyscan` (see
# deploy/README.md), never fetched live in this step — that would be
# trust-on-first-use, exactly what StrictHostKeyChecking=yes below exists to
# prevent. Raw ssh/scp are used instead of a third-party composite action
# (e.g. appleboy/ssh-action): SHA-pinning such an action doesn't audit the
# binary it wraps, and appleboy/ssh-action doesn't verify the host key unless
# `fingerprint` is explicitly passed (defaults to trusting whatever key the
# host presents).
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
KNOWN_HOSTS: ${{ secrets.KNOWN_HOSTS }}
run: |
set -euo pipefail
: "${SSH_PRIVATE_KEY:?SSH_PRIVATE_KEY secret no debe estar vacio}"
: "${KNOWN_HOSTS:?KNOWN_HOSTS secret no debe estar vacio}"
mkdir -p ~/.ssh
chmod 700 ~/.ssh
printf '%s\n' "$SSH_PRIVATE_KEY" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
printf '%s\n' "$KNOWN_HOSTS" > ~/.ssh/known_hosts
chmod 600 ~/.ssh/known_hosts

- name: Copiar frontends a la VPS
# Only the two dist/ trees are copied — not the whole $DEPLOY_PATH — for the
# same defense-in-depth reason as before: `.env.prod` and `keys/private.pem`
# also live on the host and this step has no business touching them.
#
# TMP_DIR is a relative path, not an absolute one under a hardcoded
# /home/<user> — both ssh's remote command and scp's destination resolve a
# relative path against the deploy account's home directory by convention,
# so this works exactly the same without baking the real account name into
# a public workflow file.
#
# It's also removed and recreated, not just mkdir -p'd: `scp -r` merges
# into an existing remote directory rather than mirroring it, so if the
# final cleanup step of some earlier run didn't complete (network blip,
# cancelled job), a stale file from that run would otherwise survive
# alongside this run's fresh build instead of being replaced by it.
env:
SSH_HOST: ${{ secrets.SSH_HOST }}
SSH_USER: ${{ secrets.SSH_USER }}
TMP_DIR: tmp-frontends-${{ needs.build-and-push.outputs.env_name }}
run: |
set -euo pipefail
: "${SSH_HOST:?SSH_HOST secret no debe estar vacio}"
: "${SSH_USER:?SSH_USER secret no debe estar vacio}"
SSH_OPTS=(-i ~/.ssh/deploy_key -o UserKnownHostsFile=~/.ssh/known_hosts -o StrictHostKeyChecking=yes)
ssh "${SSH_OPTS[@]}" "$SSH_USER@$SSH_HOST" "rm -rf '$TMP_DIR' && mkdir -p '$TMP_DIR'"
scp -r "${SSH_OPTS[@]}" ./frontends/landing ./frontends/web "$SSH_USER@$SSH_HOST:$TMP_DIR/"

- name: Desplegar en VPS
- name: Desplegar en VPS por SSH
# IMAGE_TAG pins the image to the immutable per-commit tag that build-and-push
# just published, instead of the mutable <env>-latest tag: between the push and
# this pull, another image could have been published over -latest, and the
Expand All @@ -268,32 +326,47 @@ jobs:
# so they don't need to be touched; the guard below fails loudly if any of them
# still don't.
#
# Assets are copied using a disposable alpine container with a bind-mount of
# $DEPLOY_PATH/www, instead of writing directly from the runner's shell: the
# deploy directory belongs to a different system user and the runner has no
# direct write access there. Only `www/` is mounted, not the whole $DEPLOY_PATH,
# because `.env.prod` and that environment's `keys/private.pem` also live in
# there, and this container has no business with them. It's defense in depth,
# not a hard boundary (whoever reaches this job already has the Docker socket),
# but it limits the blast radius of a scripting error. `docker compose` does run
# directly, because it only needs the socket and to read the compose file.
# The whole remote sequence runs as a single SSH command so a network drop
# mid-script can't leave the deploy half-applied in a way this job wouldn't
# notice: the ssh exit code reflects the remote script's own `set -euo
# pipefail` exit code.
env:
SSH_HOST: ${{ secrets.SSH_HOST }}
SSH_USER: ${{ secrets.SSH_USER }}
DEPLOY_PATH: ${{ needs.build-and-push.outputs.deploy_path }}
IMAGE_TAG: ${{ needs.build-and-push.outputs.image_tag }}
TMP_DIR: tmp-frontends-${{ needs.build-and-push.outputs.env_name }}
run: |
set -euo pipefail
# An empty IMAGE_TAG breaks both protections at once: compose would fall back to
# its default `${IMAGE_TAG:-<env>-latest}` (unpinned deploy), and the
# `*":$IMAGE_TAG"*` pattern in the guard below would degenerate into `*":"*`,
# `*":$IMAGE_TAG"*` pattern in the remote guard would degenerate into `*":"*`,
# which matches almost any image reference and would report success. Cut this
# off before anything else.
: "${SSH_HOST:?SSH_HOST secret no debe estar vacio}"
: "${SSH_USER:?SSH_USER secret no debe estar vacio}"
: "${IMAGE_TAG:?IMAGE_TAG no debe estar vacio}"
: "${DEPLOY_PATH:?DEPLOY_PATH no debe estar vacio}"
# An empty GITHUB_WORKSPACE would make the bind-mount below mount /frontends (a
# host path that normally doesn't exist): the `rm -rf` of the served assets
# would already have run inside the container by the time `cp` failed on an
# empty source.
: "${GITHUB_WORKSPACE:?GITHUB_WORKSPACE no debe estar vacio}"
# ssh joins any trailing command-line arguments with plain spaces (no
# re-quoting) before sending them to the remote shell — passing
# DEPLOY_PATH/IMAGE_TAG/TMP_DIR as separate trailing arguments to `ssh`
# would rely on them never containing a shell metacharacter to round-trip
# safely. Building the remote command as a single, already-quoted string
# with `%q` avoids that assumption entirely — same pattern the other two
# ssh/scp steps in this job already use for their own remote commands.
REMOTE_CMD=$(printf 'bash -s -- %q %q %q' "$DEPLOY_PATH" "$IMAGE_TAG" "$TMP_DIR")
ssh -i ~/.ssh/deploy_key -o UserKnownHostsFile=~/.ssh/known_hosts -o StrictHostKeyChecking=yes \
"$SSH_USER@$SSH_HOST" "$REMOTE_CMD" <<'REMOTE_SCRIPT'
set -euo pipefail
DEPLOY_PATH="$1"
IMAGE_TAG="$2"
# $3 is relative (see the env comment above) — resolved against $HOME here,
# on the host itself, rather than ever hardcoding an absolute /home/<user>
# path in the workflow source. Needed as an absolute path below: `docker
# run -v` treats a source with no leading `/` as a *named volume* to
# create, not a bind mount, which would silently mount an empty volume
# instead of the actual scp'd files.
TMP_DIR="$HOME/$3"
cd "$DEPLOY_PATH"
# Verifies that the host's compose actually resolved the image to the
# immutable tag. Without this, a compose that doesn't interpolate IMAGE_TAG
Expand All @@ -309,30 +382,33 @@ jobs:
# worse than a deploy that doesn't happen. stderr isn't silenced either — the
# real reason for the failure has to stay visible in the job log.
if ! resolved="$(docker compose -f docker-compose.prod.yml config --images)"; then
echo "::error::No se pudo resolver el compose de $DEPLOY_PATH (docker compose config --images falló; el error concreto está arriba en el log). Deploy abortado sin modificar nada."
echo "::error::No se pudo resolver el compose de $DEPLOY_PATH (docker compose config --images fallo; el error concreto esta arriba en el log). Deploy abortado sin modificar nada."
exit 1
fi
case "$resolved" in
*":$IMAGE_TAG"*)
echo "Imagen fijada a $IMAGE_TAG"
;;
*)
echo "::error::El compose de $DEPLOY_PATH no resolvió la imagen a $IMAGE_TAG (resuelto: $resolved). Revisar que use \${IMAGE_TAG:-<env>-latest}."
echo "::error::El compose de $DEPLOY_PATH no resolvio la imagen a $IMAGE_TAG (resuelto: $resolved). Revisar que use \${IMAGE_TAG:-<env>-latest}."
exit 1
;;
esac
# The pull also runs BEFORE touching the assets, for the same reason as the
# guard: it's non-destructive, and if the image can't be fetched (registry
# down, tag deleted by retention, network) the job aborts with the old
# frontends still in place. If it ran after the swap, a failed pull would
# leave new assets served against the previous backend — a version skew until
# the next successful deploy.
# Unauthenticated: ghcr.io/soy-chrislo/queres is a public package
# (ADR-0027), so no docker login is needed on this host at all — no
# long-lived registry credential left behind in ~/.docker/config.json.
# The pull runs BEFORE touching the assets, same reason as the guard
# above: it's non-destructive, and if the image can't be fetched
# (registry down, tag deleted by retention, network) the job aborts with
# the old frontends still in place.
docker compose -f docker-compose.prod.yml pull
# From here on the step is destructive to the served directory.
# The daemon creates $DEPLOY_PATH/www if it doesn't exist (root:root), the
# same as the `mkdir -p` inside the container used to do before.
# same as the `mkdir -p` inside the container used to do before. Source is
# the temp dir scp'd in the previous step, not $GITHUB_WORKSPACE — this
# runner is no longer the target host.
docker run --rm \
-v "$GITHUB_WORKSPACE/frontends:/artifacts:ro" \
-v "$TMP_DIR:/artifacts:ro" \
-v "$DEPLOY_PATH/www:/deploy/www" \
alpine:3 sh -c '
set -eu
Expand All @@ -346,3 +422,19 @@ jobs:
# possible.
docker compose -f docker-compose.prod.yml up -d --remove-orphans
docker image prune -f
REMOTE_SCRIPT

- name: Limpiar directorio temporal en VPS
# if: always() — must run even if an earlier step failed, so a bad deploy
# doesn't leave stale frontend artifacts on the host. Best-effort: if the SSH
# key was never written (failure before "Configurar acceso SSH"), this would
# itself fail: `|| true` keeps that from masking the real earlier failure with
# a second, unrelated one.
if: always()
env:
SSH_HOST: ${{ secrets.SSH_HOST }}
SSH_USER: ${{ secrets.SSH_USER }}
TMP_DIR: tmp-frontends-${{ needs.build-and-push.outputs.env_name }}
run: |
ssh -i ~/.ssh/deploy_key -o UserKnownHostsFile=~/.ssh/known_hosts -o StrictHostKeyChecking=yes \
"$SSH_USER@$SSH_HOST" "rm -rf '$TMP_DIR'" || true
Loading
Loading