# ----------------------------------------------------------------------------- # cameleer-website — Deploy to Hetzner Webhosting L # # MANUAL TRIGGER ONLY. Runs exclusively on workflow_dispatch from the Gitea UI # (Actions → deploy → Run workflow). Does NOT auto-deploy on push to main — # merges to main must be explicitly promoted to production. # # Build and deploy run in a single job so the built dist/ (including # dotfiles like .htaccess) flows directly into rsync. An earlier split-job # design was abandoned because actions/upload-artifact@v3 excludes dotfiles # by default and the v4 client does not work on Gitea Actions / GHES. # # 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 # PUBLIC_AUTH_SIGNIN_URL, PUBLIC_AUTH_SIGNUP_URL, PUBLIC_SALES_EMAIL # ----------------------------------------------------------------------------- name: deploy on: workflow_dispatch: concurrency: group: deploy-production cancel-in-progress: false jobs: deploy: runs-on: ubuntu-latest timeout-minutes: 25 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 # Astro/Vite does not copy dotfiles from public/ into dist/, so .htaccess # never reaches the deployed origin and Apache never sees the security # headers it sets. Copy it explicitly. Fail if the source is missing # rather than silently shipping a header-less site. - name: Copy .htaccess into dist run: | test -f public/.htaccess cp public/.htaccess dist/.htaccess - name: Guard — no TBD markers may ship in built HTML run: | if grep -rlE '(TBD):' dist 2>/dev/null | grep -E '\.(html|svg)$'; then echo "Built output contains unfilled ) markers." echo "Fill in imprint.astro and privacy.astro operator fields before merging to main." exit 1 fi - 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."