8 Commits

Author SHA1 Message Date
hsiegeln
ca2a725953 ci(deploy): merge build+deploy into one job, manual trigger only
All checks were successful
ci / build-test (push) Successful in 4m0s
Two changes:

1. Merge build and deploy jobs into a single 'deploy' job. This
   eliminates the actions/upload-artifact@v3 round-trip, which was
   silently stripping dotfiles (.htaccess) from the artifact and
   leaving the deployed origin without security headers. The built
   dist/ (including .htaccess) now flows directly into rsync in the
   same workspace.

2. Remove the 'push: branches: [main]' trigger so deploy runs only
   on workflow_dispatch (manual click in Gitea Actions UI).
   Merges to main no longer auto-deploy — production promotion is
   an explicit user action.

The concurrency group at workflow level still prevents overlapping
deploys. All secrets remain unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 21:24:42 +02:00
hsiegeln
fdb0411c35 Sync main into feat/initial-build before merge-jobs refactor 2026-04-24 21:23:58 +02:00
hsiegeln
461b5e0cd6 Merge branch 'feat/initial-build' into main
Some checks failed
deploy / build (push) Successful in 36s
deploy / deploy (push) Failing after 14s
ci / build-test (push) Successful in 3m54s
Copy public/.htaccess into dist after Astro build (Astro/Vite drops
dotfiles from public/ otherwise, leaving the origin without HSTS).

# Conflicts:
#	.gitea/workflows/deploy.yml
2026-04-24 21:09:35 +02:00
hsiegeln
0d743402ac ci(deploy): copy public/.htaccess into dist after build
All checks were successful
ci / build-test (push) Successful in 3m47s
Astro/Vite drops dotfiles from public/ during build, so .htaccess
never makes it into dist/. The deployed Apache origin then has no
header rules to apply, leaving the site without HSTS, X-Frame-Options,
Referrer-Policy, etc. — caught today by the post-deploy smoke test
("HSTS missing").

Copy the file explicitly after build. test -f makes the step fail
loudly if public/.htaccess goes missing, rather than silently
shipping a header-less site.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 21:08:51 +02:00
hsiegeln
28fcaf16c5 Merge branch 'feat/initial-build' into main
Some checks failed
ci / build-test (push) Successful in 4m2s
deploy / build (push) Successful in 30s
deploy / deploy (push) Failing after 13s
Revert to rsync, route through Hetzner's SSH port 222 (the shell port,
as opposed to port 22 which is SFTP-only).
2026-04-24 20:24:33 +02:00
hsiegeln
e3fbbbada7 ci(deploy): revert to rsync via SSH port 222 (Hetzner shell port)
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>
2026-04-24 20:24:27 +02:00
hsiegeln
cb21be71f0 Merge branch 'feat/initial-build' into main
Some checks failed
ci / build-test (push) Successful in 3m55s
deploy / build (push) Successful in 28s
deploy / deploy (push) Failing after 12s
Fix lftp auth (explicit -u USER, + unindented heredoc body).
2026-04-24 20:08:29 +02:00
hsiegeln
5417565e34 ci(deploy): fix lftp auth — explicit empty password + unindented script
All checks were successful
ci / build-test (push) Successful in 4m0s
Two issues from the previous lftp run:
- "GetPass() failed -- assume anonymous login" / "Password required":
  without `-u USER,` (trailing comma = empty password), lftp tries
  to prompt for a password instead of relying on the ssh key passed
  via sftp:connect-program.
- Heredoc body was indented with leading whitespace; lftp can mis-
  parse leading-whitespace lines as command continuations.

Also bump verbosity (`debug 3`) so the ssh command lftp launches
is logged — makes the next failure easier to read — and bound
retries to 1 so we fail fast in CI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 20:08:22 +02:00

View File

@@ -1,10 +1,14 @@
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# cameleer-website — Deploy to Hetzner Webhosting L # cameleer-website — Deploy to Hetzner Webhosting L
# #
# Runs ONLY on pushes to `main` and on manual dispatch from the Gitea UI. # MANUAL TRIGGER ONLY. Runs exclusively on workflow_dispatch from the Gitea UI
# Does NOT run Lighthouse CI (that's in ci.yml — assume any commit that reached # (Actions → deploy → Run workflow). Does NOT auto-deploy on push to main —
# main already passed the full gate). Rebuilds fresh, runs the TBD guard, and # merges to main must be explicitly promoted to production.
# rsyncs `dist/` to the origin over SSH with host-key pinning. #
# 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 # 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 # labels differ. Deploy target is Hetzner amd64 — arch mismatch is a non-issue
@@ -12,15 +16,12 @@
# #
# Required secrets (repo settings → Actions → Secrets): # Required secrets (repo settings → Actions → Secrets):
# SFTP_HOST, SFTP_USER, SFTP_PATH, SFTP_KEY, SFTP_KNOWN_HOSTS # 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 # PUBLIC_AUTH_SIGNIN_URL, PUBLIC_AUTH_SIGNUP_URL, PUBLIC_SALES_EMAIL
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
name: deploy name: deploy
on: on:
push:
branches: [main]
workflow_dispatch: workflow_dispatch:
concurrency: concurrency:
@@ -28,9 +29,9 @@ concurrency:
cancel-in-progress: false cancel-in-progress: false
jobs: jobs:
build: deploy:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 15 timeout-minutes: 25
env: env:
PUBLIC_AUTH_SIGNIN_URL: ${{ secrets.PUBLIC_AUTH_SIGNIN_URL }} PUBLIC_AUTH_SIGNIN_URL: ${{ secrets.PUBLIC_AUTH_SIGNIN_URL }}
PUBLIC_AUTH_SIGNUP_URL: ${{ secrets.PUBLIC_AUTH_SIGNUP_URL }} PUBLIC_AUTH_SIGNUP_URL: ${{ secrets.PUBLIC_AUTH_SIGNUP_URL }}
@@ -53,6 +54,15 @@ jobs:
- name: Build site - name: Build site
run: npm run build 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 - name: Guard — no TBD markers may ship in built HTML
run: | run: |
if grep -rlE '(TBD):' dist 2>/dev/null | grep -E '\.(html|svg)$'; then if grep -rlE '(TBD):' dist 2>/dev/null | grep -E '\.(html|svg)$'; then
@@ -61,28 +71,6 @@ jobs:
exit 1 exit 1
fi 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 - name: Configure SSH
env: env:
SFTP_KEY: ${{ secrets.SFTP_KEY }} SFTP_KEY: ${{ secrets.SFTP_KEY }}
@@ -96,35 +84,33 @@ jobs:
chmod 600 ~/.ssh/id_ed25519 chmod 600 ~/.ssh/id_ed25519
printf '%s\n' "$SFTP_KNOWN_HOSTS" > ~/.ssh/known_hosts printf '%s\n' "$SFTP_KNOWN_HOSTS" > ~/.ssh/known_hosts
chmod 644 ~/.ssh/known_hosts chmod 644 ~/.ssh/known_hosts
# Hetzner Webhosting accounts are SFTP-only — they accept SSH for file # Ensure rsync + openssh are present even on a minimal runner image.
# transfer but refuse remote command exec ("exec request failed on if ! command -v rsync >/dev/null 2>&1 || ! command -v ssh >/dev/null 2>&1; then
# 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 if command -v sudo >/dev/null 2>&1; then SUDO=sudo; else SUDO=; fi
$SUDO apt-get update -qq $SUDO apt-get update -qq
$SUDO apt-get install -y --no-install-recommends lftp openssh-client $SUDO apt-get install -y --no-install-recommends rsync openssh-client
fi fi
- name: Deploy via lftp (mirror over SFTP) - name: Deploy via rsync
env: env:
SFTP_USER: ${{ secrets.SFTP_USER }} SFTP_USER: ${{ secrets.SFTP_USER }}
SFTP_HOST: ${{ secrets.SFTP_HOST }} SFTP_HOST: ${{ secrets.SFTP_HOST }}
SFTP_PATH: ${{ secrets.SFTP_PATH }} SFTP_PATH: ${{ secrets.SFTP_PATH }}
run: | run: |
# Fail loudly if any secret is missing — otherwise mirror --delete # Fail loudly if any secret is missing — otherwise rsync --delete
# could be directed at the SSH user's home root. # could be directed at the SSH user's home root.
: "${SFTP_USER:?SFTP_USER secret must be set}" : "${SFTP_USER:?SFTP_USER secret must be set}"
: "${SFTP_HOST:?SFTP_HOST secret must be set}" : "${SFTP_HOST:?SFTP_HOST secret must be set}"
: "${SFTP_PATH:?SFTP_PATH secret must be set}" : "${SFTP_PATH:?SFTP_PATH secret must be set}"
lftp <<LFTP # Hetzner Webhosting splits SSH into two ports:
set cmd:fail-exit yes # port 22 — SFTP only, no remote command exec
set sftp:connect-program 'ssh -a -x -i $HOME/.ssh/id_ed25519 -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$HOME/.ssh/known_hosts' # port 222 — full SSH with shell exec (rsync needs this)
open sftp://$SFTP_USER@$SFTP_HOST # `--rsync-path=/usr/bin/rsync` tells the local rsync where to find
mirror --reverse --delete --verbose --parallel=4 dist/ $SFTP_PATH/ # the remote binary on Hetzner's locked-down PATH.
bye # `BatchMode=yes` disables interactive prompts.
LFTP 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 - name: Post-deploy smoke test
run: | run: |