diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 4ad4ab1e..a0554e27 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -23,6 +23,11 @@ name: release # - host ports: backend 8080, frontend 3000 # - profile: (none) — mailpit is excluded; real SMTP relay is used # +# The obs-stack deploy, Caddy reload, and smoke test are shared with +# nightly.yml via the composite actions under .gitea/actions/ (ADR-029). +# actions/checkout MUST stay the first step: a local `uses: ./…` action +# only exists on disk after checkout. +# # Required Gitea secrets: # PROD_POSTGRES_PASSWORD # PROD_MINIO_PASSWORD @@ -53,6 +58,8 @@ jobs: # advertised label of our single-tenant self-hosted runner. runs-on: ubuntu-latest steps: + # MUST be first: the composite actions below live under .gitea/actions/ + # and only exist on disk once the repo is checked out (ADR-029). - uses: actions/checkout@v4 - name: Write production env file @@ -100,117 +107,21 @@ jobs: --env-file .env.production \ up -d --wait --remove-orphans - - name: Deploy observability configs - # Mirrors the nightly approach: copies obs compose file and config tree - # to /opt/familienarchiv/ (permanent path, survives workspace wipes — ADR-016), - # then writes obs-secrets.env fresh from Gitea secrets. - # Non-secret config lives in infra/observability/obs.env (tracked in git). - run: | - rm -rf /opt/familienarchiv/infra/observability - mkdir -p /opt/familienarchiv/infra/observability - cp -r infra/observability/. /opt/familienarchiv/infra/observability/ - cp docker-compose.observability.yml /opt/familienarchiv/ - cat > /opt/familienarchiv/obs-secrets.env <<'EOF' - GRAFANA_ADMIN_PASSWORD=${{ secrets.GRAFANA_ADMIN_PASSWORD }} - GRAFANA_DB_PASSWORD=${{ secrets.GRAFANA_DB_PASSWORD }} - GLITCHTIP_SECRET_KEY=${{ secrets.GLITCHTIP_SECRET_KEY }} - POSTGRES_PASSWORD=${{ secrets.PROD_POSTGRES_PASSWORD }} - POSTGRES_HOST=archiv-production-db-1 - EOF - # Note: POSTGRES_HOST is derived from the Compose project name (archiv-production) - # and service name (db). A project rename requires updating this value. - chmod 600 /opt/familienarchiv/obs-secrets.env + # POSTGRES_HOST is derived from the Compose project name (archiv-production) + # and service name (db). A project rename requires updating this value. + - uses: ./.gitea/actions/deploy-obs + with: + grafana_admin_password: ${{ secrets.GRAFANA_ADMIN_PASSWORD }} + grafana_db_password: ${{ secrets.GRAFANA_DB_PASSWORD }} + glitchtip_secret_key: ${{ secrets.GLITCHTIP_SECRET_KEY }} + postgres_password: ${{ secrets.PROD_POSTGRES_PASSWORD }} + postgres_host: archiv-production-db-1 - - name: Validate observability compose config - # Dry-run: resolves all variable substitutions and reports any missing - # required keys before containers start. Catches undefined variables and - # YAML errors in config files updated by the previous step. - # --env-file order: obs.env first (git-tracked defaults), obs-secrets.env - # second (CI-written secrets). Later files win on duplicate keys, so - # obs-secrets.env overrides POSTGRES_HOST set in obs.env. - # Keep in sync with the equivalent step in nightly.yml (#603). - run: | - docker compose \ - -f /opt/familienarchiv/docker-compose.observability.yml \ - --env-file /opt/familienarchiv/infra/observability/obs.env \ - --env-file /opt/familienarchiv/obs-secrets.env \ - config --quiet + - uses: ./.gitea/actions/reload-caddy - - name: Start observability stack - # Runs with absolute paths so bind mounts resolve to stable host paths - # that survive workspace wipes between runs (see ADR-016). - # Non-secret config from obs.env (git-tracked); secrets from obs-secrets.env - # (written fresh from Gitea secrets above). --env-file order: obs.env first, - # obs-secrets.env second — later file wins on duplicate keys. - # Keep in sync with the equivalent step in nightly.yml (#603). - run: | - docker compose \ - -f /opt/familienarchiv/docker-compose.observability.yml \ - --env-file /opt/familienarchiv/infra/observability/obs.env \ - --env-file /opt/familienarchiv/obs-secrets.env \ - up -d --wait --remove-orphans - - - name: Assert observability stack health - # docker compose up --wait covers services WITH healthcheck directives only. - # obs-promtail, obs-cadvisor, obs-node-exporter, and obs-glitchtip-worker have - # no healthcheck — they are considered "started" as soon as the process runs. - # This step explicitly asserts the five healthchecked critical services are - # healthy before the smoke test proceeds. - # Keep in sync with the equivalent step in nightly.yml (#603). - run: | - set -e - unhealthy="" - for svc in obs-loki obs-prometheus obs-grafana obs-tempo obs-glitchtip; do - status=$(docker inspect "$svc" --format '{{.State.Health.Status}}' 2>/dev/null || echo "missing") - if [ "$status" != "healthy" ]; then - echo "::error::$svc is not healthy (status: $status)" - unhealthy="$unhealthy $svc" - fi - done - [ -z "$unhealthy" ] || exit 1 - echo "All critical observability services are healthy" - - - name: Reload Caddy - # See nightly.yml — same rationale and mechanism: DooD job containers - # cannot call systemctl directly; nsenter via a privileged sibling - # container reaches the host systemd. Must run after deploy (so the - # latest Caddyfile is on disk) and before the smoke test (so the - # public surface reflects the current config). Alpine with pinned - # digest; reload not restart — see nightly.yml for full rationale. - run: | - docker run --rm --privileged --pid=host \ - alpine:3.21@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d65087abc07d \ - sh -c 'apk add --no-cache util-linux -q && nsenter -t 1 -m -u -n -p -i -- /bin/systemctl reload caddy' - - - name: Smoke test deployed environment - # See nightly.yml — same three checks, against the prod vhost. - # --resolve stored as a Bash array so "${RESOLVE[@]}" expands to two - # separate arguments; a quoted string would pass the flag and its value - # as one token and curl would reject it as an unknown option. - # Gateway detection via /proc/net/route — no iproute2 dependency. - # See nightly.yml for the full network topology explanation. - run: | - set -e - HOST="archiv.raddatz.cloud" - URL="https://$HOST" - HOST_IP=$(awk 'NR>1 && $2=="00000000"{h=$3;printf "%d.%d.%d.%d\n",strtonum("0x"substr(h,7,2)),strtonum("0x"substr(h,5,2)),strtonum("0x"substr(h,3,2)),strtonum("0x"substr(h,1,2));exit}' /proc/net/route) - [ -n "$HOST_IP" ] || { echo "ERROR: could not detect Docker bridge gateway via /proc/net/route"; exit 1; } - RESOLVE=(--resolve "$HOST:443:$HOST_IP") - echo "Smoke test: $URL (pinned to $HOST_IP via bridge gateway)" - curl -fsS "${RESOLVE[@]}" --max-time 10 "$URL/login" -o /dev/null - # Pin the preload-list-eligible HSTS value, not just header presence: - # a degraded `max-age=1` or a dropped `includeSubDomains; preload` must - # fail this check rather than pass it silently. - curl -fsS "${RESOLVE[@]}" --max-time 10 -I "$URL/" \ - | grep -Eqi 'strict-transport-security:[[:space:]]*max-age=31536000.*includeSubDomains.*preload' - # Permissions-Policy denies APIs the app does not use (camera, - # microphone, geolocation). A regression that loosens or drops the - # header now fails the smoke step. - curl -fsS "${RESOLVE[@]}" --max-time 10 -I "$URL/" \ - | grep -Eqi 'permissions-policy:[[:space:]]*camera=\(\),[[:space:]]*microphone=\(\),[[:space:]]*geolocation=\(\)' - status=$(curl -s "${RESOLVE[@]}" -o /dev/null -w "%{http_code}" --max-time 10 "$URL/actuator/health") - [ "$status" = "404" ] || { echo "expected 404 from /actuator/health, got $status"; exit 1; } - echo "All smoke checks passed" + - uses: ./.gitea/actions/smoke-test + with: + host: archiv.raddatz.cloud - name: Cleanup env file # LOAD-BEARING: `if: always()` is the linchpin of the ADR-011