# ----------------------------------------------------------------------------- # 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 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 # 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 # Hetzner Webhosting accounts are SFTP-only — they accept SSH for file # transfer but refuse remote command exec ("exec request failed on # channel 0"). rsync over SSH needs to spawn a remote rsync binary, # so it cannot work here. Use lftp's mirror instead, which speaks # SFTP end-to-end with the same key + known_hosts pinning. if ! command -v lftp >/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 lftp openssh-client fi - name: Deploy via lftp (mirror over SFTP) 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 mirror --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}" # `-u USER,` (with trailing comma = empty password) tells lftp not # to prompt for a password; auth happens via the key passed to ssh # by sftp:connect-program. Heredoc body is unindented so lftp's # parser doesn't mistake leading whitespace for a continuation. # `debug 3` prints the ssh command lftp invokes — useful if this # ever breaks again. lftp <