Compare commits

...

5 Commits

Author SHA1 Message Date
luxick 3dd467de5f Use correct image for latest o-fahrt
Build & Deploy / build (push) Has been cancelled
2026-07-30 16:33:52 +02:00
luxick f7ea9cde45 Show all nav items on mobile instead of collapsing
The menu collapsed to a hamburger below 640px, which was roughly twice
the width the items actually need: measured with Segoe UI Semibold, the
five entries occupy 296px against 343px available at a 375px viewport.

Drops the toggle entirely and scales the column gap and font size with
clamp() instead, so there is no breakpoint at all. flex-wrap handles the
extreme case -- below ~330px the row wraps to two lines, which still
shows every item rather than hiding them.

Also removes the checkbox-and-label toggle hack, which announced itself
as a checkbox and carried no aria-expanded state.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 16:23:57 +02:00
luxick 2fcb2fd219 Don't force StrictHostKeyChecking in the local deploy script
The script runs interactively against a host the operator has already
connected to, so pinning the option added nothing. Removing the override
lets ssh use its default (ask): it consults the user's own known_hosts
and prompts once on first connect, and still refuses a changed host key
afterwards.

Left as-is in CI, which has no prompt and pins the key via the
SSH_KNOWN_HOSTS secret.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 16:13:58 +02:00
luxick 5ee02c34f0 Add a workstation deploy script
The Gitea runner is not up yet, so deploys need to be possible by hand.
scripts/deploy.sh mirrors the workflow: same hugo build, same rsync
invocation, same DEPLOY_PATH guards.

It previews every change and reports the number of server-side deletions
before asking to continue, since rsync runs with --delete. Credentials
live in the gitignored scripts/deploy.env.

Two Windows-specific guards, both hit in practice: it refuses an SSH key
under /mnt (WSL cannot hold 0600 there, so ssh rejects it) and it fails
with a clear message when run from Git Bash, which has no rsync.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 16:05:56 +02:00
luxick aea3aa47ae Deploy to a single target
There is only one server. The site is served from /var/www/mcg on the VPS
at mcg.luxick.de today, and the main domain will be pointed at the same
instance at cutover; local `hugo server` covers development.

Drops the development/production split from the workflow, its
DEPLOY_PATH_DEV / DEPLOY_PATH_PROD secrets and the workflow_dispatch
environment input, leaving a single DEPLOY_PATH. baseURL now comes from
hugo.toml rather than being injected per environment, so it is the one
place to change at cutover.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 15:49:39 +02:00
9 changed files with 215 additions and 76 deletions
+6 -28
View File
@@ -5,16 +5,7 @@ on:
branches: [main] branches: [main]
pull_request: pull_request:
branches: [main] branches: [main]
workflow_dispatch: workflow_dispatch: # manual re-deploy of the current main
inputs:
environment:
description: 'Deployment target'
required: true
default: 'development'
type: choice
options:
- development
- production
env: env:
HUGO_VERSION: '0.164.0' HUGO_VERSION: '0.164.0'
@@ -36,35 +27,22 @@ jobs:
sudo dpkg -i /tmp/hugo.deb sudo dpkg -i /tmp/hugo.deb
hugo version hugo version
- name: Resolve target # baseURL comes from hugo.toml - that is the single place to change when
id: target # the site moves to its final domain.
run: |
set -euo pipefail
if [ "${{ github.event_name }}" = "workflow_dispatch" ] \
&& [ "${{ inputs.environment }}" = "production" ]; then
echo "base_url=https://motorradclub-giebelwald.de/" >> "$GITHUB_OUTPUT"
echo "deploy_path=${{ secrets.DEPLOY_PATH_PROD }}" >> "$GITHUB_OUTPUT"
echo "name=production" >> "$GITHUB_OUTPUT"
else
echo "base_url=https://mcg.luxick.de/" >> "$GITHUB_OUTPUT"
echo "deploy_path=${{ secrets.DEPLOY_PATH_DEV }}" >> "$GITHUB_OUTPUT"
echo "name=development" >> "$GITHUB_OUTPUT"
fi
- name: Build - name: Build
run: | run: |
set -euo pipefail set -euo pipefail
hugo --gc --minify --baseURL "${{ steps.target.outputs.base_url }}" hugo --gc --minify
# Pull requests are built to catch breakage, but never published. # Pull requests are built to catch breakage, but never published.
- name: Deploy to ${{ steps.target.outputs.name }} - name: Deploy
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
env: env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }} SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_USER: ${{ secrets.DEPLOY_USER }} DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
DEPLOY_PATH: ${{ steps.target.outputs.deploy_path }} DEPLOY_PATH: ${{ secrets.DEPLOY_PATH }}
run: | run: |
set -euo pipefail set -euo pipefail
+3
View File
@@ -5,3 +5,6 @@
# Local scratch space # Local scratch space
/.cache/ /.cache/
# Local deploy credentials
/scripts/deploy.env
+48 -13
View File
@@ -2,8 +2,9 @@
Static website of the Motorradclub Giebelwald e.V., built with [Hugo](https://gohugo.io). Static website of the Motorradclub Giebelwald e.V., built with [Hugo](https://gohugo.io).
- Development: <https://mcg.luxick.de> Currently served at <https://mcg.luxick.de>. Once the site is signed off, the
- Production: <https://motorradclub-giebelwald.de> main domain <https://motorradclub-giebelwald.de> will be pointed at the same
server and the old host retired — see [Changing the domain](#changing-the-domain).
## Requirements ## Requirements
@@ -91,12 +92,37 @@ layouts/ baseof, home, list, page, termine
## Deployment ## Deployment
`.gitea/workflows/deploy.yml` builds with Hugo and rsyncs `public/` over SSH. `.gitea/workflows/deploy.yml` builds with Hugo and rsyncs `public/` over SSH to
the VPS webroot.
- Push to `main`deploys to **mcg.luxick.de** - Push to `main`builds and deploys
- Manual *Run workflow* with `environment: production` → deploys to - *Run workflow* → manual re-deploy of the current `main`
**motorradclub-giebelwald.de** - Pull requests are built to catch breakage, but never published
- Pull requests are built but never published
There is one deploy target. For development, run the site locally with
`hugo server`.
### Deploying from a workstation
`scripts/deploy.sh` does the same thing without CI — useful while the Gitea
runner is not set up. It needs `rsync`, so run it from WSL or another Linux
shell, not Git Bash or PowerShell.
```sh
cp scripts/deploy.env.example scripts/deploy.env # then fill it in (gitignored)
./scripts/deploy.sh --dry-run # preview, upload nothing
./scripts/deploy.sh # build, preview, confirm, deploy
```
| Flag | Effect |
|---|---|
| `--dry-run` | Stop after the preview |
| `--yes` | Skip the confirmation prompt |
| `--skip-build` | Deploy the existing `public/`, e.g. after building on Windows |
The script previews every change and reports how many files would be **deleted**
on the server before asking to continue. Keep the SSH key inside the WSL
filesystem — a key under `/mnt/c` cannot hold `0600`, and ssh will reject it.
### Required secrets ### Required secrets
@@ -105,12 +131,21 @@ Set these in the Gitea repository settings:
| Secret | Example | | Secret | Example |
|---|---| |---|---|
| `SSH_PRIVATE_KEY` | Deploy key with write access to the webroot | | `SSH_PRIVATE_KEY` | Deploy key with write access to the webroot |
| `SSH_KNOWN_HOSTS` | Output of `ssh-keyscan <host>` | | `SSH_KNOWN_HOSTS` | Verified output of `ssh-keyscan <host>` |
| `DEPLOY_HOST` | `server.example.de` | | `DEPLOY_HOST` | `server.example.de` |
| `DEPLOY_USER` | `www-deploy` | | `DEPLOY_USER` | `www-deploy` |
| `DEPLOY_PATH_DEV` | `/var/www/mcg-dev` | | `DEPLOY_PATH` | `/var/www/mcg` |
| `DEPLOY_PATH_PROD` | `/var/www/mcg` |
rsync runs with `--delete`, so each `DEPLOY_PATH_*` must point at a directory rsync runs with `--delete`, so `DEPLOY_PATH` must point at a directory owned
owned solely by this site. The workflow refuses to deploy if the path is unset, solely by this site. The workflow refuses to deploy if the path is unset, too
too shallow, or if the build produced no `index.html`. shallow, or if the build produced no `index.html`.
### Changing the domain
`baseURL` in `hugo.toml` is the only place the live domain is configured; it
feeds canonical URLs, `og:url`, the RSS feed and the sitemap. To cut over:
1. Point the DNS record at the VPS.
2. Add the domain to the nginx `server_name` and reissue the certificate.
3. Change `baseURL` in `hugo.toml` and push — the deploy rebuilds every
absolute URL.
+7 -27
View File
@@ -109,20 +109,14 @@ h3 { font-size: 1.2rem; margin: var(--space-3) 0 var(--space-1); }
z-index: 20; z-index: 20;
} }
.nav-toggle { position: absolute; opacity: 0; pointer-events: none; } /* No hamburger: the five menu items need roughly 316px at the smallest gap, so
they fit on one line on any modern phone. The column gap scales with the
.nav-toggle-label { viewport, and flex-wrap drops to a second row in the extreme case (very
display: none; narrow screen or large text zoom) rather than hiding anything. */
padding: var(--space-2) 0;
color: #fff;
font-weight: 600;
cursor: pointer;
}
.nav-list { .nav-list {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: var(--space-3); gap: 0 clamp(0.75rem, 0.2rem + 2.2vw, 1.5rem);
list-style: none; list-style: none;
margin: 0; margin: 0;
padding: 0; padding: 0;
@@ -130,30 +124,16 @@ h3 { font-size: 1.2rem; margin: var(--space-3) 0 var(--space-1); }
.nav-list a { .nav-list a {
display: block; display: block;
padding: 0.85rem 0; padding: 0.85rem 0; /* keeps a ~44px touch target */
color: #fff; color: #fff;
text-decoration: none; text-decoration: none;
font-weight: 600; font-weight: 600;
font-size: 0.95rem; font-size: clamp(0.875rem, 0.83rem + 0.2vw, 0.95rem);
border-bottom: 3px solid transparent; border-bottom: 3px solid transparent;
} }
.nav-list a:hover { color: #fff; border-bottom-color: rgb(255 255 255 / 45%); } .nav-list a:hover { color: #fff; border-bottom-color: rgb(255 255 255 / 45%); }
.nav-list a[aria-current="page"] { border-bottom-color: #fff; } .nav-list a[aria-current="page"] { border-bottom-color: #fff; }
@media (max-width: 640px) {
.nav-toggle-label { display: block; }
.nav-list {
display: none;
flex-direction: column;
gap: 0;
padding-bottom: var(--space-2);
}
.nav-toggle:checked ~ .nav-list { display: flex; }
.nav-list a { padding: 0.6rem 0; border-bottom: 0; }
.nav-list a[aria-current="page"] { text-decoration: underline; }
.nav-toggle:focus-visible ~ .nav-toggle-label { outline: 3px solid #fff; }
}
/* ----------------------------------------------------------------- main */ /* ----------------------------------------------------------------- main */
.site-main { padding-block: var(--space-4) var(--space-5); } .site-main { padding-block: var(--space-4) var(--space-5); }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 488 KiB

+2 -4
View File
@@ -1,3 +1,5 @@
# The live domain. Change this one line when the site moves to
# https://motorradclub-giebelwald.de/ - it feeds canonical URLs, RSS and sitemap.
baseURL = 'https://mcg.luxick.de/' baseURL = 'https://mcg.luxick.de/'
title = 'Motorradclub Giebelwald e.V.' title = 'Motorradclub Giebelwald e.V.'
locale = 'de-DE' locale = 'de-DE'
@@ -46,10 +48,6 @@ buildFuture = false
name = 'Archiv' name = 'Archiv'
pageRef = '/archiv' pageRef = '/archiv'
weight = 40 weight = 40
[[menus.main]]
name = 'Impressum'
pageRef = '/impressum'
weight = 50
[outputs] [outputs]
home = ['html', 'rss', 'sitemap'] home = ['html', 'rss', 'sitemap']
-4
View File
@@ -22,10 +22,6 @@
<nav class="site-nav" aria-label="Hauptnavigation"> <nav class="site-nav" aria-label="Hauptnavigation">
<div class="container"> <div class="container">
<input type="checkbox" id="nav-toggle" class="nav-toggle">
<label for="nav-toggle" class="nav-toggle-label">
<span aria-hidden="true"></span> Menü
</label>
<ul class="nav-list"> <ul class="nav-list">
<li><a href="{{ "/" | relURL }}"{{ if .IsHome }} aria-current="page"{{ end }}>Start</a></li> <li><a href="{{ "/" | relURL }}"{{ if .IsHome }} aria-current="page"{{ end }}>Start</a></li>
{{ range site.Menus.main }} {{ range site.Menus.main }}
+10
View File
@@ -0,0 +1,10 @@
# Copy to scripts/deploy.env (gitignored) and fill in.
# Must match the values used by the Gitea Actions secrets.
DEPLOY_HOST=server.example.de
DEPLOY_USER=www-deploy
DEPLOY_PATH=/var/www/mcg
# Private half of the deploy key. Keep it inside the WSL filesystem —
# a key under /mnt/c or /mnt/d cannot hold 0600 and ssh will reject it.
SSH_KEY=$HOME/.ssh/mcg-deploy
+139
View File
@@ -0,0 +1,139 @@
#!/usr/bin/env bash
#
# Manual deploy from a workstation, for use until the Gitea runner is up.
# Mirrors .gitea/workflows/deploy.yml: same build, same rsync, same guards.
#
# ./scripts/deploy.sh build, preview the changes, confirm, deploy
# ./scripts/deploy.sh --dry-run stop after the preview
# ./scripts/deploy.sh --yes skip the confirmation prompt
# ./scripts/deploy.sh --skip-build deploy whatever is already in public/
#
# Configuration is read from scripts/deploy.env (gitignored); see
# scripts/deploy.env.example. Environment variables take precedence.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$REPO_ROOT"
DRY_RUN=0
ASSUME_YES=0
SKIP_BUILD=0
for arg in "$@"; do
case "$arg" in
--dry-run) DRY_RUN=1 ;;
--yes|-y) ASSUME_YES=1 ;;
--skip-build) SKIP_BUILD=1 ;;
-h|--help) sed -n '3,13p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "unknown option: $arg (try --help)" >&2; exit 2 ;;
esac
done
die() { echo "error: $*" >&2; exit 1; }
# ---------------------------------------------------------------- config ---
CONFIG="${DEPLOY_ENV_FILE:-scripts/deploy.env}"
if [ -f "$CONFIG" ]; then
# shellcheck disable=SC1090
. "$CONFIG"
elif [ -z "${DEPLOY_HOST:-}" ]; then
die "no $CONFIG and no DEPLOY_HOST in the environment.
Copy scripts/deploy.env.example to $CONFIG and fill it in."
fi
: "${DEPLOY_HOST:?set DEPLOY_HOST}"
: "${DEPLOY_USER:?set DEPLOY_USER}"
: "${DEPLOY_PATH:?set DEPLOY_PATH}"
SSH_KEY="${SSH_KEY:-$HOME/.ssh/mcg-deploy}"
# rsync runs with --delete, so the destination becomes an exact mirror of
# public/. Refuse to run against an unset, root or shallow path.
case "$DEPLOY_PATH" in
""|"/"|"/root"|"/home"|"/var"|"/etc"|"/usr"|"/srv"|"/opt")
die "DEPLOY_PATH is unsafe: '$DEPLOY_PATH'" ;;
esac
if [ "$(printf '%s' "$DEPLOY_PATH" | tr -cd '/' | wc -c)" -lt 2 ]; then
die "DEPLOY_PATH '$DEPLOY_PATH' is too shallow to delete into"
fi
# --------------------------------------------------------- dependencies ---
for cmd in rsync ssh; do
command -v "$cmd" >/dev/null 2>&1 || die "'$cmd' is not installed.
Run this script from WSL or another Linux shell — Git Bash and
PowerShell do not ship rsync."
done
# ------------------------------------------------------------- ssh key ---
[ -f "$SSH_KEY" ] || die "ssh key not found: $SSH_KEY"
case "$SSH_KEY" in
/mnt/*)
die "the ssh key is on the Windows filesystem ($SSH_KEY).
WSL cannot hold 0600 there, so ssh will reject the key. Copy it into
the WSL home directory instead:
cp '$SSH_KEY' ~/.ssh/mcg-deploy && chmod 600 ~/.ssh/mcg-deploy" ;;
esac
perms="$(stat -c '%a' "$SSH_KEY" 2>/dev/null || echo '')"
if [ -n "$perms" ] && [ "$perms" != "600" ] && [ "$perms" != "400" ]; then
echo "==> Tightening permissions on $SSH_KEY (was $perms)"
chmod 600 "$SSH_KEY"
fi
# --------------------------------------------------------------- build ---
if [ "$SKIP_BUILD" -eq 0 ]; then
command -v hugo >/dev/null 2>&1 || die "hugo is not on PATH.
Either install Hugo 0.164.0 in WSL, or build on the Windows side and
re-run this script with --skip-build."
echo "==> Building"
rm -rf public
hugo --gc --minify
fi
[ -f public/index.html ] || \
die "public/index.html missing - refusing to deploy an empty build"
# -------------------------------------------------------------- deploy ---
# No StrictHostKeyChecking override: ssh falls back to its default (ask), so it
# uses your personal ~/.ssh/known_hosts and prompts once on first connect. CI
# pins it to yes instead, because it has no prompt and a known_hosts secret.
SSH_CMD="ssh -i $SSH_KEY -o IdentitiesOnly=yes"
TARGET="${DEPLOY_USER}@${DEPLOY_HOST}:${DEPLOY_PATH}/"
RSYNC_OPTS=(-az --delete --checksum)
preview="$(mktemp)"
trap 'rm -f "$preview"' EXIT
echo "==> Previewing changes against $TARGET"
rsync "${RSYNC_OPTS[@]}" --itemize-changes --dry-run \
-e "$SSH_CMD" public/ "$TARGET" | tee "$preview"
deletions="$(grep -c '^\*deleting' "$preview" || true)"
updates="$(grep -cv '^\*deleting' "$preview" || true)"
echo
echo "==> $updates file(s) to send, $deletions to delete on the server"
if [ "$DRY_RUN" -eq 1 ]; then
echo "==> Dry run only, nothing was uploaded."
exit 0
fi
if [ "$ASSUME_YES" -eq 0 ]; then
printf 'Continue? [y/N] '
read -r reply
case "$reply" in
[yY]|[yY][eE][sS]) ;;
*) echo "aborted"; exit 1 ;;
esac
fi
echo "==> Deploying"
rsync "${RSYNC_OPTS[@]}" --human-readable --stats \
-e "$SSH_CMD" public/ "$TARGET"
echo "==> Done"