All checks were successful
ci / build-test (push) Successful in 3m57s
Hetzner Webhosting exposes SSH on TWO ports:
port 22 — SFTP only, refuses remote command exec
port 222 — full SSH with shell, supports rsync
Previous deploys hit "exec request failed on channel 0" because we
were using port 22. Switch back from lftp to plain rsync, but route
it through port 222 with --rsync-path=/usr/bin/rsync (Hetzner's
locked-down PATH doesn't include rsync by default) and BatchMode=yes
to disable interactive prompts.
Mirrors the working local command:
rsync -avz --rsync-path=/usr/bin/rsync \
-e "ssh -p 222 -i ~/.ssh/id_ed25519_gitea -o BatchMode=yes" \
./ apibny@www691.your-server.de:/usr/www/users/apibny/www.cameleer.io
Keeps host-key pinning (StrictHostKeyChecking + UserKnownHostsFile)
which the local command omits because the user's personal known_hosts
already trusts the host.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
136 lines
5.2 KiB
YAML
136 lines
5.2 KiB
YAML
# -----------------------------------------------------------------------------
|
|
# cameleer-website — Deploy to Hetzner Webhosting L
|
|
#
|
|
# Runs ONLY on pushes to `main` and on manual dispatch from the Gitea UI.
|
|
# Does NOT run Lighthouse CI (that's in ci.yml — assume any commit that reached
|
|
# main already passed the full gate). Rebuilds fresh, runs the TBD guard, and
|
|
# rsyncs `dist/` to the origin over SSH with host-key pinning.
|
|
#
|
|
# Runner: self-hosted arm64 Gitea runner. Adjust `runs-on` if your runner's
|
|
# labels differ. Deploy target is Hetzner amd64 — arch mismatch is a non-issue
|
|
# because the bundle is static HTML/CSS/JS.
|
|
#
|
|
# Required secrets (repo settings → Actions → Secrets):
|
|
# SFTP_HOST, SFTP_USER, SFTP_PATH, SFTP_KEY, SFTP_KNOWN_HOSTS
|
|
# Required variables (repo settings → Actions → Variables):
|
|
# PUBLIC_AUTH_SIGNIN_URL, PUBLIC_AUTH_SIGNUP_URL, PUBLIC_SALES_EMAIL
|
|
# -----------------------------------------------------------------------------
|
|
|
|
name: deploy
|
|
|
|
on:
|
|
push:
|
|
branches: [main]
|
|
workflow_dispatch:
|
|
|
|
concurrency:
|
|
group: deploy-production
|
|
cancel-in-progress: false
|
|
|
|
jobs:
|
|
build:
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 15
|
|
env:
|
|
PUBLIC_AUTH_SIGNIN_URL: ${{ secrets.PUBLIC_AUTH_SIGNIN_URL }}
|
|
PUBLIC_AUTH_SIGNUP_URL: ${{ secrets.PUBLIC_AUTH_SIGNUP_URL }}
|
|
PUBLIC_SALES_EMAIL: ${{ secrets.PUBLIC_SALES_EMAIL }}
|
|
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
|
|
- uses: actions/setup-node@v4
|
|
with:
|
|
node-version: '20'
|
|
cache: 'npm'
|
|
|
|
- name: Install dependencies
|
|
run: npm ci
|
|
|
|
- name: Run unit tests (sanity check)
|
|
run: npm test
|
|
|
|
- name: Build site
|
|
run: npm run build
|
|
|
|
- name: Guard — no TODO markers may ship in built HTML
|
|
run: |
|
|
if grep -rlE '(TODO|TBD):' dist 2>/dev/null | grep -E '\.(html|svg)$'; then
|
|
echo "Built output contains unfilled <TODO:...> (or legacy <TBD:...>) markers."
|
|
echo "Fill in imprint.astro and privacy.astro operator fields before merging to main."
|
|
exit 1
|
|
fi
|
|
|
|
# Pin to v3 — Gitea Actions implements the v3 artifact protocol.
|
|
# upload/download-artifact@v4 talk to a github.com-only backend and
|
|
# fail with GHESNotSupportedError on Gitea / Forgejo / GHES.
|
|
- name: Upload dist artifact
|
|
uses: actions/upload-artifact@v3
|
|
with:
|
|
name: dist
|
|
path: dist/
|
|
retention-days: 7
|
|
|
|
deploy:
|
|
needs: build
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 10
|
|
|
|
steps:
|
|
- name: Download dist artifact
|
|
uses: actions/download-artifact@v3
|
|
with:
|
|
name: dist
|
|
path: dist/
|
|
|
|
- name: Configure SSH
|
|
env:
|
|
SFTP_KEY: ${{ secrets.SFTP_KEY }}
|
|
SFTP_KNOWN_HOSTS: ${{ secrets.SFTP_KNOWN_HOSTS }}
|
|
run: |
|
|
set -e
|
|
: "${SFTP_KEY:?SFTP_KEY secret must be set}"
|
|
: "${SFTP_KNOWN_HOSTS:?SFTP_KNOWN_HOSTS secret must be set}"
|
|
mkdir -p ~/.ssh
|
|
printf '%s\n' "$SFTP_KEY" > ~/.ssh/id_ed25519
|
|
chmod 600 ~/.ssh/id_ed25519
|
|
printf '%s\n' "$SFTP_KNOWN_HOSTS" > ~/.ssh/known_hosts
|
|
chmod 644 ~/.ssh/known_hosts
|
|
# Ensure rsync + openssh are present even on a minimal runner image.
|
|
if ! command -v rsync >/dev/null 2>&1 || ! command -v ssh >/dev/null 2>&1; then
|
|
if command -v sudo >/dev/null 2>&1; then SUDO=sudo; else SUDO=; fi
|
|
$SUDO apt-get update -qq
|
|
$SUDO apt-get install -y --no-install-recommends rsync openssh-client
|
|
fi
|
|
|
|
- name: Deploy via rsync
|
|
env:
|
|
SFTP_USER: ${{ secrets.SFTP_USER }}
|
|
SFTP_HOST: ${{ secrets.SFTP_HOST }}
|
|
SFTP_PATH: ${{ secrets.SFTP_PATH }}
|
|
run: |
|
|
# Fail loudly if any secret is missing — otherwise rsync --delete
|
|
# could be directed at the SSH user's home root.
|
|
: "${SFTP_USER:?SFTP_USER secret must be set}"
|
|
: "${SFTP_HOST:?SFTP_HOST secret must be set}"
|
|
: "${SFTP_PATH:?SFTP_PATH secret must be set}"
|
|
# Hetzner Webhosting splits SSH into two ports:
|
|
# port 22 — SFTP only, no remote command exec
|
|
# port 222 — full SSH with shell exec (rsync needs this)
|
|
# `--rsync-path=/usr/bin/rsync` tells the local rsync where to find
|
|
# the remote binary on Hetzner's locked-down PATH.
|
|
# `BatchMode=yes` disables interactive prompts.
|
|
rsync -avz --delete --rsync-path=/usr/bin/rsync \
|
|
-e "ssh -p 222 -i $HOME/.ssh/id_ed25519 -o BatchMode=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$HOME/.ssh/known_hosts" \
|
|
dist/ "$SFTP_USER@$SFTP_HOST:$SFTP_PATH/"
|
|
|
|
- name: Post-deploy smoke test
|
|
run: |
|
|
set -e
|
|
echo "Checking security headers on www.cameleer.io..."
|
|
HEADERS=$(curl -sI https://www.cameleer.io/ || echo "")
|
|
echo "$HEADERS" | grep -i '^strict-transport-security:' || { echo "HSTS missing"; exit 1; }
|
|
echo "$HEADERS" | grep -i '^content-security-policy:' || { echo "CSP missing"; exit 1; }
|
|
echo "$HEADERS" | grep -i '^x-frame-options:' || { echo "XFO missing"; exit 1; }
|
|
echo "All required headers present on the live origin."
|