diff --git a/.forgejo/workflows/cd-apps.yml b/.forgejo/workflows/cd-apps.yml new file mode 100644 index 0000000..913b73f --- /dev/null +++ b/.forgejo/workflows/cd-apps.yml @@ -0,0 +1,146 @@ +name: CD Apps +# Ported from .github/workflows/cd-apps.yml for Forgejo Actions. +# Differences from the GitHub version: +# - no `environment: production` (Forgejo has no GitHub Environments) +# - image push to ghcr.io authenticates with DEPLOY_GHCR_TOKEN (decrypted +# from SOPS) as user `stigi`, since Forgejo's auto token can't push to +# GHCR the way GitHub's GITHUB_TOKEN could. +on: + push: + branches: [main] + paths: + - "apps/**" + - "packages/**" + - "pnpm-lock.yaml" + workflow_dispatch: {} + +concurrency: + group: deploy-apps + cancel-in-progress: true + +env: + SENTRY_DSN_JOURNAL: "https://a32ffcc575d34be072e91b20f247eeee@o4509530546634752.ingest.de.sentry.io/4509530555547728" + SENTRY_DSN_PLANNER: "https://5215134cd78d5e6c199e29300b8425af@o4509530546634752.ingest.de.sentry.io/4511102546608208" + +jobs: + build-images: + name: Build & Push Docker Images + runs-on: ubuntu-latest + strategy: + matrix: + app: [journal, planner] + steps: + - uses: actions/checkout@v7 + + - name: Decrypt secrets + login to GHCR + run: | + curl -sLO https://github.com/getsops/sops/releases/download/v3.9.4/sops-v3.9.4.linux.amd64 + chmod +x sops-v3.9.4.linux.amd64 + SOPS_AGE_KEY="${{ secrets.AGE_SECRET_KEY }}" ./sops-v3.9.4.linux.amd64 -d infrastructure/secrets.app.env > /tmp/secrets.env + grep SENTRY_AUTH_TOKEN /tmp/secrets.env | cut -d= -f2- | tr -d '\n' > /tmp/sentry_token + # GHCR push credential (Forgejo has no GITHUB_TOKEN with packages:write). + grep '^DEPLOY_GHCR_TOKEN=' /tmp/secrets.env | cut -d= -f2- \ + | docker login ghcr.io -u stigi --password-stdin + + - uses: docker/build-push-action@v7 + with: + context: . + file: apps/${{ matrix.app }}/Dockerfile + push: true + tags: | + ghcr.io/trails-cool/${{ matrix.app }}:latest + ghcr.io/trails-cool/${{ matrix.app }}:${{ github.sha }} + build-args: | + SENTRY_RELEASE=${{ github.sha }} + VITE_SENTRY_DSN=${{ matrix.app == 'journal' && env.SENTRY_DSN_JOURNAL || '' }} + secrets: | + SENTRY_AUTH_TOKEN=/tmp/sentry_token + + deploy: + name: Deploy Apps + needs: [build-images] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Decrypt secrets + run: | + curl -sLO https://github.com/getsops/sops/releases/download/v3.9.4/sops-v3.9.4.linux.amd64 + chmod +x sops-v3.9.4.linux.amd64 + SOPS_AGE_KEY="${{ secrets.AGE_SECRET_KEY }}" ./sops-v3.9.4.linux.amd64 -d infrastructure/secrets.app.env > infrastructure/app.env + echo "SENTRY_RELEASE=${{ github.sha }}" >> infrastructure/app.env + echo "DOMAIN=trails.cool" >> infrastructure/app.env + echo "IS_FLAGSHIP=true" >> infrastructure/app.env + echo "FEDERATION_ENABLED=true" >> infrastructure/app.env + echo "FEDERATION_LOG_LEVEL=info" >> infrastructure/app.env + echo "SENTRY_DSN_JOURNAL=$SENTRY_DSN_JOURNAL" >> infrastructure/app.env + echo "SENTRY_DSN_PLANNER=$SENTRY_DSN_PLANNER" >> infrastructure/app.env + + - name: Copy files to server + uses: appleboy/scp-action@v1 + with: + host: ${{ secrets.DEPLOY_HOST }} + username: root + key: ${{ secrets.DEPLOY_SSH_KEY }} + source: "infrastructure/docker-compose.yml,infrastructure/caddy" + target: /opt/trails-cool + strip_components: 1 + + - name: Copy secrets to server + uses: appleboy/scp-action@v1 + with: + host: ${{ secrets.DEPLOY_HOST }} + username: root + key: ${{ secrets.DEPLOY_SSH_KEY }} + source: "infrastructure/app.env" + target: /opt/trails-cool + strip_components: 1 + + - name: Deploy via SSH + uses: appleboy/ssh-action@v1 + with: + host: ${{ secrets.DEPLOY_HOST }} + username: root + key: ${{ secrets.DEPLOY_SSH_KEY }} + script: | + set -euo pipefail + cd /opt/trails-cool + + GHCR_TOKEN=$(grep DEPLOY_GHCR_TOKEN app.env | cut -d= -f2-) + echo "$GHCR_TOKEN" | docker login ghcr.io -u stigi --password-stdin + + docker compose --env-file app.env pull journal planner + docker compose --env-file app.env run --rm journal node --experimental-strip-types /app/packages/db/src/migrate-data.ts + docker compose --env-file app.env run --rm journal npx drizzle-kit push --config /app/packages/db/drizzle.config.ts --force 2>&1 | tee /tmp/drizzle-push.log + if grep -q "Error:" /tmp/drizzle-push.log; then + echo "drizzle-kit push reported an error — failing the deploy" + exit 1 + fi + docker compose --env-file app.env up -d --remove-orphans journal planner + + for svc in journal planner; do + for i in $(seq 1 24); do + status=$(docker inspect -f '{{.State.Health.Status}}' "trails-cool-$svc-1" 2>/dev/null || echo missing) + [ "$status" = "healthy" ] && break + sleep 5 + done + if [ "$status" != "healthy" ]; then + echo "$svc did not become healthy (last status: $status)" + docker compose --env-file app.env logs "$svc" --tail 50 || true + exit 1 + fi + done + + docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile || true + + docker image prune -af || true + docker compose ps + + GRAFANA_TOKEN=$(grep GRAFANA_SERVICE_TOKEN .env 2>/dev/null | cut -d= -f2-) + if [ -n "$GRAFANA_TOKEN" ]; then + docker compose exec -T grafana curl -sf -X POST \ + -H "Authorization: Bearer $GRAFANA_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"text":"Deploy ${{ github.sha }}","tags":["deploy","apps"]}' \ + http://localhost:3000/api/annotations || true + fi diff --git a/.forgejo/workflows/cd-brouter.yml b/.forgejo/workflows/cd-brouter.yml new file mode 100644 index 0000000..9744231 --- /dev/null +++ b/.forgejo/workflows/cd-brouter.yml @@ -0,0 +1,116 @@ +name: CD BRouter +# Ported from .github/workflows/cd-brouter.yml for Forgejo Actions. +# Differences: no `environment: infra`; the image push logs in to ghcr.io +# with DEPLOY_GHCR_TOKEN (decrypted) instead of the GitHub auto token. +on: + push: + branches: [main] + paths: + - "docker/brouter/**" + - "infrastructure/brouter-host/**" + workflow_dispatch: {} + +concurrency: + group: deploy-brouter + cancel-in-progress: true + +jobs: + build: + name: Build & Push BRouter Image + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Decrypt secret + login to GHCR + run: | + curl -sLO https://github.com/getsops/sops/releases/download/v3.9.4/sops-v3.9.4.linux.amd64 + chmod +x sops-v3.9.4.linux.amd64 + SOPS_AGE_KEY="${{ secrets.AGE_SECRET_KEY }}" ./sops-v3.9.4.linux.amd64 -d infrastructure/secrets.app.env \ + | grep '^DEPLOY_GHCR_TOKEN=' | cut -d= -f2- \ + | docker login ghcr.io -u stigi --password-stdin + + - uses: docker/build-push-action@v7 + with: + context: docker/brouter + push: true + tags: | + ghcr.io/trails-cool/brouter:latest + ghcr.io/trails-cool/brouter:${{ github.sha }} + + deploy: + name: Deploy BRouter to dedicated host + needs: [build] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Decrypt shared secret + id: decrypt + run: | + curl -sLO https://github.com/getsops/sops/releases/download/v3.9.4/sops-v3.9.4.linux.amd64 + chmod +x sops-v3.9.4.linux.amd64 + SOPS_AGE_KEY="${{ secrets.AGE_SECRET_KEY }}" ./sops-v3.9.4.linux.amd64 -d infrastructure/secrets.app.env \ + | grep '^BROUTER_AUTH_TOKEN=' > infrastructure/brouter-host/.env + chmod 0600 infrastructure/brouter-host/.env + + GHCR_TOKEN=$(SOPS_AGE_KEY="${{ secrets.AGE_SECRET_KEY }}" ./sops-v3.9.4.linux.amd64 -d infrastructure/secrets.app.env \ + | grep '^DEPLOY_GHCR_TOKEN=' | cut -d= -f2-) + echo "::add-mask::$GHCR_TOKEN" + echo "GHCR_TOKEN=$GHCR_TOKEN" >> $GITHUB_ENV + + - name: Deploy to dedicated host (single SSH connection) + env: + SSH_KEY: ${{ secrets.BROUTER_DEPLOY_SSH_KEY }} + HOST: ${{ secrets.BROUTER_DEPLOY_HOST }} + PORT: ${{ secrets.BROUTER_DEPLOY_SSH_PORT }} + run: | + set -euo pipefail + + install -m 0600 /dev/null ~/id_deploy + printf '%s\n' "$SSH_KEY" > ~/id_deploy + + TARBALL_B64=$(tar -C infrastructure/brouter-host -czf - \ + docker-compose.yml Caddyfile promtail-config.yml \ + download-segments.sh .env \ + poi-extract \ + | base64 -w0) + + ssh -i ~/id_deploy -p "$PORT" \ + -o StrictHostKeyChecking=accept-new \ + -o UserKnownHostsFile=/tmp/known_hosts \ + -o ConnectTimeout=30 \ + trails@"$HOST" \ + "TARBALL_B64='$TARBALL_B64' GHCR_TOKEN='$GHCR_TOKEN' bash -s" <<'REMOTE' + set -euo pipefail + mkdir -p ~/brouter && cd ~/brouter + echo "$TARBALL_B64" | base64 -d | tar -xzf - + chmod +x download-segments.sh poi-extract/poi-extract.sh poi-extract/to-ndjson.py + + if [ ! -d segments ] || [ -z "$(ls -A segments 2>/dev/null)" ]; then + echo "WARNING: segments/ is empty. BRouter will start but return 404 until segments are seeded." + mkdir -p segments + fi + + echo "$GHCR_TOKEN" | docker login ghcr.io -u stigi --password-stdin + + docker compose pull + docker compose up -d --remove-orphans + docker compose ps + REMOTE + + - name: Annotate deploy in flagship Grafana + uses: appleboy/ssh-action@v1 + with: + host: ${{ secrets.DEPLOY_HOST }} + username: root + key: ${{ secrets.DEPLOY_SSH_KEY }} + script: | + cd /opt/trails-cool + GRAFANA_TOKEN=$(grep GRAFANA_SERVICE_TOKEN .env | cut -d= -f2-) + if [ -n "$GRAFANA_TOKEN" ]; then + docker compose exec -T grafana curl -sf -X POST \ + -H "Authorization: Bearer $GRAFANA_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"text":"Deploy brouter ${{ github.sha }}","tags":["deploy","brouter"]}' \ + http://localhost:3000/api/annotations || true + fi diff --git a/.forgejo/workflows/cd-infra.yml b/.forgejo/workflows/cd-infra.yml new file mode 100644 index 0000000..4715a0e --- /dev/null +++ b/.forgejo/workflows/cd-infra.yml @@ -0,0 +1,130 @@ +name: CD Infra +# Ported from .github/workflows/cd-infra.yml for Forgejo Actions. +# Only difference: no `environment: infra`. No image build here — GHCR login +# happens on the server using DEPLOY_GHCR_TOKEN from the merged .env. +on: + push: + branches: [main] + paths: + - "infrastructure/**" + workflow_dispatch: + inputs: + restart_all: + description: "Restart all containers (not just infra)" + type: boolean + default: false + +concurrency: + group: deploy-infra + cancel-in-progress: true + +jobs: + deploy: + name: Deploy Infrastructure + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Decrypt secrets + run: | + curl -sLO https://github.com/getsops/sops/releases/download/v3.9.4/sops-v3.9.4.linux.amd64 + chmod +x sops-v3.9.4.linux.amd64 + SOPS_AGE_KEY="${{ secrets.AGE_SECRET_KEY }}" ./sops-v3.9.4.linux.amd64 -d infrastructure/secrets.app.env > infrastructure/.env + SOPS_AGE_KEY="${{ secrets.AGE_SECRET_KEY }}" ./sops-v3.9.4.linux.amd64 -d infrastructure/secrets.infra.env >> infrastructure/.env + echo "DOMAIN=trails.cool" >> infrastructure/.env + echo "IS_FLAGSHIP=true" >> infrastructure/.env + + - name: Copy configs to server + uses: appleboy/scp-action@v1 + with: + host: ${{ secrets.DEPLOY_HOST }} + username: root + key: ${{ secrets.DEPLOY_SSH_KEY }} + source: "infrastructure/docker-compose.yml,infrastructure/caddy,infrastructure/prometheus/prometheus.yml,infrastructure/loki/loki-config.yml,infrastructure/promtail/promtail-config.yml,infrastructure/postgres/queries.yml,infrastructure/postgres/init-grafana-user.sql,infrastructure/grafana/provisioning,infrastructure/grafana/dashboards,infrastructure/scripts" + target: /opt/trails-cool + strip_components: 1 + + - name: Copy secrets to server + uses: appleboy/scp-action@v1 + with: + host: ${{ secrets.DEPLOY_HOST }} + username: root + key: ${{ secrets.DEPLOY_SSH_KEY }} + source: "infrastructure/.env" + target: /opt/trails-cool + strip_components: 1 + + - name: Deploy via SSH + uses: appleboy/ssh-action@v1 + with: + host: ${{ secrets.DEPLOY_HOST }} + username: root + key: ${{ secrets.DEPLOY_SSH_KEY }} + script: | + set -euo pipefail + cd /opt/trails-cool + + GHCR_TOKEN=$(grep DEPLOY_GHCR_TOKEN .env | cut -d= -f2-) + echo "$GHCR_TOKEN" | docker login ghcr.io -u stigi --password-stdin 2>/dev/null || true + + docker compose exec -T postgres psql -U trails -d trails -f /docker-entrypoint-initdb.d/init-grafana-user.sql 2>/dev/null || true + GRAFANA_DB_PW=$(grep GRAFANA_DB_PASSWORD .env | cut -d= -f2-) + if [ -n "$GRAFANA_DB_PW" ]; then + docker compose exec -T postgres psql -U trails -d trails -c "ALTER ROLE grafana_reader PASSWORD '$GRAFANA_DB_PW'" 2>/dev/null || true + fi + + PROMETHEUS_BEFORE_ID=$(docker inspect -f '{{.Id}}' trails-cool-prometheus-1 2>/dev/null || true) + + if [ "${{ github.event.inputs.restart_all }}" = "true" ]; then + docker compose --env-file .env up -d --remove-orphans + else + docker compose --env-file .env up -d --remove-orphans postgres prometheus loki promtail grafana postgres-exporter node-exporter cadvisor + fi + + PROMETHEUS_AFTER_ID=$(docker inspect -f '{{.Id}}' trails-cool-prometheus-1 2>/dev/null || true) + + if [ -n "$PROMETHEUS_BEFORE_ID" ] && [ "$PROMETHEUS_BEFORE_ID" = "$PROMETHEUS_AFTER_ID" ]; then + docker compose --env-file .env kill -s SIGHUP prometheus + fi + docker compose --env-file .env restart loki promtail + docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile + + docker compose ps + + for ctr in trails-cool-postgres-1 trails-cool-journal-1; do + for i in $(seq 1 36); do + status=$(docker inspect -f '{{.State.Health.Status}}' "$ctr" 2>/dev/null || echo missing) + [ "$status" = "healthy" ] && break + sleep 5 + done + if [ "$status" != "healthy" ]; then + echo "$ctr did not become healthy (last status: $status)" + exit 1 + fi + done + + PROMETHEUS_READY= + for i in $(seq 1 36); do + prom_status=$(docker inspect -f '{{.State.Status}}' trails-cool-prometheus-1 2>/dev/null || echo missing) + if [ "$prom_status" = "running" ]; then + prom_ip=$(docker inspect -f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' trails-cool-prometheus-1) + if curl -sf "http://$prom_ip:9090/-/ready" >/dev/null; then + PROMETHEUS_READY=1 + break + fi + fi + sleep 5 + done + if [ -z "$PROMETHEUS_READY" ]; then + echo "trails-cool-prometheus-1 did not become ready (last status: $prom_status)" + exit 1 + fi + + GRAFANA_TOKEN=$(grep GRAFANA_SERVICE_TOKEN .env | cut -d= -f2-) + if [ -n "$GRAFANA_TOKEN" ]; then + docker compose exec -T grafana curl -sf -X POST \ + -H "Authorization: Bearer $GRAFANA_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"text":"Deploy infra ${{ github.sha }}","tags":["deploy","infra"]}' \ + http://localhost:3000/api/annotations || true + fi diff --git a/.forgejo/workflows/cd-staging.yml b/.forgejo/workflows/cd-staging.yml new file mode 100644 index 0000000..7ce88da --- /dev/null +++ b/.forgejo/workflows/cd-staging.yml @@ -0,0 +1,428 @@ +name: CD Staging +# Ported from .github/workflows/cd-staging.yml for Forgejo Actions. +# +# Adaptations applied: +# - no `environment: production` +# - image push logs in to ghcr.io with DEPLOY_GHCR_TOKEN (decrypted) +# +# NEEDS VALIDATION on a real Forgejo PR before relying on it — these depend on +# GitHub-Actions semantics Forgejo may implement differently: +# - pull_request action types (labeled/unlabeled/synchronize) and +# `github.event.pull_request.labels.*.name` filtering +# - the peter-evans find-comment / create-or-update-comment actions, which +# talk to the API as octokit; the bot author is `forgejo-actions[bot]` +# (not `github-actions[bot]`), so the comment-author match likely needs +# updating once confirmed. +on: + push: + branches: [main] + paths: + - "apps/**" + - "packages/**" + - "pnpm-lock.yaml" + - "infrastructure/docker-compose.staging.yml" + - ".forgejo/workflows/cd-staging.yml" + pull_request: + types: [opened, synchronize, reopened, closed, labeled, unlabeled] + paths: + - "apps/**" + - "packages/**" + - "pnpm-lock.yaml" + workflow_dispatch: {} + +concurrency: + group: staging-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.number) || 'main' }} + cancel-in-progress: true + +env: + SENTRY_DSN_JOURNAL: "https://a32ffcc575d34be072e91b20f247eeee@o4509530546634752.ingest.de.sentry.io/4509530555547728" + SENTRY_DSN_PLANNER: "https://5215134cd78d5e6c199e29300b8425af@o4509530546634752.ingest.de.sentry.io/4511102546608208" + +jobs: + build-images: + name: Build & Push Docker Images + if: > + github.event_name != 'pull_request' || + (github.event.action != 'closed' && + (contains(github.event.pull_request.labels.*.name, 'preview') || + contains(github.event.pull_request.body, ''))) + runs-on: ubuntu-latest + strategy: + matrix: + app: [journal, planner] + outputs: + tag_primary: ${{ steps.tags.outputs.primary }} + tag_sha: ${{ steps.tags.outputs.sha }} + steps: + - uses: actions/checkout@v7 + + - id: tags + name: Compute image tags + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + PRIMARY="pr-${{ github.event.number }}" + SHA="pr-${{ github.event.number }}-${{ github.event.pull_request.head.sha }}" + else + PRIMARY="staging" + SHA="${{ github.sha }}" + fi + echo "primary=$PRIMARY" >> "$GITHUB_OUTPUT" + echo "sha=$SHA" >> "$GITHUB_OUTPUT" + + - name: Decrypt secrets + login to GHCR + run: | + curl -sLO https://github.com/getsops/sops/releases/download/v3.9.4/sops-v3.9.4.linux.amd64 + chmod +x sops-v3.9.4.linux.amd64 + SOPS_AGE_KEY="${{ secrets.AGE_SECRET_KEY }}" ./sops-v3.9.4.linux.amd64 -d infrastructure/secrets.app.env > /tmp/secrets.env + grep SENTRY_AUTH_TOKEN /tmp/secrets.env | cut -d= -f2- | tr -d '\n' > /tmp/sentry_token + grep '^DEPLOY_GHCR_TOKEN=' /tmp/secrets.env | cut -d= -f2- \ + | docker login ghcr.io -u stigi --password-stdin + + - uses: docker/build-push-action@v7 + with: + context: . + file: apps/${{ matrix.app }}/Dockerfile + push: true + tags: | + ghcr.io/trails-cool/${{ matrix.app }}:${{ steps.tags.outputs.primary }} + ghcr.io/trails-cool/${{ matrix.app }}:${{ steps.tags.outputs.sha }} + build-args: | + SENTRY_RELEASE=${{ steps.tags.outputs.sha }} + VITE_SENTRY_DSN=${{ matrix.app == 'journal' && env.SENTRY_DSN_JOURNAL || '' }} + secrets: | + SENTRY_AUTH_TOKEN=/tmp/sentry_token + + deploy-staging: + name: Deploy Staging + if: (github.event_name == 'push' && github.ref == 'refs/heads/main') || github.event_name == 'workflow_dispatch' + needs: [build-images] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Decrypt secrets + run: | + curl -sLO https://github.com/getsops/sops/releases/download/v3.9.4/sops-v3.9.4.linux.amd64 + chmod +x sops-v3.9.4.linux.amd64 + SOPS_AGE_KEY="${{ secrets.AGE_SECRET_KEY }}" ./sops-v3.9.4.linux.amd64 -d infrastructure/secrets.app.env > infrastructure/staging.env + { + echo "DOMAIN=staging.trails.cool" + echo "STAGING_DATABASE=trails_staging" + echo "JOURNAL_HOST_PORT=3110" + echo "PLANNER_HOST_PORT=3111" + echo "JOURNAL_IMAGE_TAG=staging" + echo "PLANNER_IMAGE_TAG=staging" + echo "SENTRY_RELEASE=${{ github.sha }}" + echo "SENTRY_DSN_JOURNAL=$SENTRY_DSN_JOURNAL" + echo "SENTRY_DSN_PLANNER=$SENTRY_DSN_PLANNER" + echo "FEDERATION_ENABLED=true" + echo "FEDERATION_LOG_LEVEL=debug" + } >> infrastructure/staging.env + + - name: Copy compose file + env to server + uses: appleboy/scp-action@v1 + with: + host: ${{ secrets.DEPLOY_HOST }} + username: root + key: ${{ secrets.DEPLOY_SSH_KEY }} + source: "infrastructure/docker-compose.staging.yml,infrastructure/staging.env" + target: /opt/trails-cool + strip_components: 1 + + - name: Deploy via SSH + uses: appleboy/ssh-action@v1 + with: + host: ${{ secrets.DEPLOY_HOST }} + username: root + key: ${{ secrets.DEPLOY_SSH_KEY }} + script: | + set -euo pipefail + cd /opt/trails-cool + + GHCR_TOKEN=$(grep DEPLOY_GHCR_TOKEN staging.env | cut -d= -f2-) + echo "$GHCR_TOKEN" | docker login ghcr.io -u stigi --password-stdin + + docker network inspect trails-shared >/dev/null 2>&1 || docker network create trails-shared + PG_CONTAINER=$(docker ps --filter "name=trails-cool-postgres" --format '{{.Names}}' | head -1) + if [ -n "$PG_CONTAINER" ]; then + docker network connect trails-shared "$PG_CONTAINER" 2>/dev/null || true + fi + + docker compose exec -T postgres psql -U trails -d postgres -tAc \ + "SELECT 1 FROM pg_database WHERE datname='trails_staging'" \ + | grep -q 1 \ + || docker compose exec -T postgres createdb -U trails trails_staging + docker compose exec -T postgres psql -U trails -d trails_staging -c \ + "CREATE EXTENSION IF NOT EXISTS postgis" + + docker compose -f docker-compose.staging.yml -p trails-staging --env-file staging.env --profile persistent pull + docker compose -f docker-compose.staging.yml -p trails-staging --env-file staging.env --profile persistent run --rm journal npx drizzle-kit push --config /app/packages/db/drizzle.config.ts --force 2>&1 | tee /tmp/drizzle-push.log + if grep -q "Error:" /tmp/drizzle-push.log; then + echo "drizzle-kit push reported an error — failing the deploy" + exit 1 + fi + docker compose -f docker-compose.staging.yml -p trails-staging --env-file staging.env --profile persistent up -d --remove-orphans + + docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile || true + + docker image prune -af --filter "until=1h" || true + + docker compose -f docker-compose.staging.yml -p trails-staging --env-file staging.env --profile persistent ps + + deploy-preview: + name: Deploy PR Preview + if: > + github.event_name == 'pull_request' && + github.event.action != 'closed' && + !startsWith(github.head_ref, 'dependabot/github_actions/') && + (contains(github.event.pull_request.labels.*.name, 'preview') || + contains(github.event.pull_request.body, '')) + needs: [build-images] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - id: ports + name: Compute preview ports + project name + run: | + PR=${{ github.event.number }} + JOURNAL_PORT=$((3200 + 2 * PR)) + PLANNER_PORT=$((3201 + 2 * PR)) + echo "pr=$PR" >> "$GITHUB_OUTPUT" + echo "journal_port=$JOURNAL_PORT" >> "$GITHUB_OUTPUT" + echo "planner_port=$PLANNER_PORT" >> "$GITHUB_OUTPUT" + echo "host=pr-$PR.staging.trails.cool" >> "$GITHUB_OUTPUT" + echo "project=trails-pr-$PR" >> "$GITHUB_OUTPUT" + echo "database=trails_pr_$PR" >> "$GITHUB_OUTPUT" + + - name: Decrypt secrets + assemble env + run: | + curl -sLO https://github.com/getsops/sops/releases/download/v3.9.4/sops-v3.9.4.linux.amd64 + chmod +x sops-v3.9.4.linux.amd64 + SOPS_AGE_KEY="${{ secrets.AGE_SECRET_KEY }}" ./sops-v3.9.4.linux.amd64 -d infrastructure/secrets.app.env > infrastructure/staging-pr-${{ steps.ports.outputs.pr }}.env + { + echo "DOMAIN=${{ steps.ports.outputs.host }}" + echo "STAGING_DATABASE=${{ steps.ports.outputs.database }}" + echo "JOURNAL_HOST_PORT=${{ steps.ports.outputs.journal_port }}" + echo "PLANNER_HOST_PORT=${{ steps.ports.outputs.planner_port }}" + echo "JOURNAL_IMAGE_TAG=pr-${{ steps.ports.outputs.pr }}" + echo "PLANNER_IMAGE_TAG=pr-${{ steps.ports.outputs.pr }}" + echo "PLANNER_URL=https://planner.staging.trails.cool" + echo "SENTRY_RELEASE=${{ github.event.pull_request.head.sha }}" + echo "SENTRY_DSN_JOURNAL=$SENTRY_DSN_JOURNAL" + echo "SENTRY_DSN_PLANNER=$SENTRY_DSN_PLANNER" + echo "FEDERATION_ENABLED=true" + echo "FEDERATION_LOG_LEVEL=debug" + } >> infrastructure/staging-pr-${{ steps.ports.outputs.pr }}.env + + - name: Generate per-PR Caddyfile snippet + run: | + mkdir -p infrastructure/sites + cat > infrastructure/sites/pr-${{ steps.ports.outputs.pr }}.caddyfile </tmp/trails-preview-deploy.lock + flock --timeout 300 9 || { echo "Timed out waiting for deploy lock after 300s"; exit 1; } + + docker network inspect trails-shared >/dev/null 2>&1 || docker network create trails-shared + PG_CONTAINER=$(docker ps --filter "name=trails-cool-postgres" --format '{{.Names}}' | head -1) + if [ -n "$PG_CONTAINER" ]; then + docker network connect trails-shared "$PG_CONTAINER" 2>/dev/null || true + fi + + ACTIVE=$(docker compose ls --format json --filter "name=trails-pr-" | python3 -c 'import json,sys; data=json.load(sys.stdin); print("\n".join(d["Name"] for d in data))' 2>/dev/null || true) + if [ -n "$ACTIVE" ] && ! echo "$ACTIVE" | grep -qx "$PROJECT"; then + COUNT=$(echo "$ACTIVE" | grep -c '^trails-pr-' || true) + if [ "$COUNT" -ge 3 ]; then + OLDEST=$(docker ps -a --filter "name=trails-pr-" --format '{{.Names}} {{.CreatedAt}}' \ + | awk '{ split($1,a,"-"); print "trails-pr-"a[3], $2" "$3" "$4 }' \ + | sort -k2 \ + | head -1 \ + | awk '{print $1}') + if [ -n "$OLDEST" ] && [ "$OLDEST" != "$PROJECT" ]; then + echo "At cap; evicting oldest preview: $OLDEST" + OLD_PR=${OLDEST#trails-pr-} + OLD_ENV="staging-pr-${OLD_PR}.env" + EVICT_ENV=$( [ -f "$OLD_ENV" ] && echo "$OLD_ENV" || echo "staging.env" ) + docker compose -f docker-compose.staging.yml -p "$OLDEST" --env-file "$EVICT_ENV" down --remove-orphans || true + docker compose exec -T postgres dropdb -U trails --if-exists "trails_pr_$OLD_PR" || true + rm -f "sites/pr-$OLD_PR.caddyfile" "$OLD_ENV" + fi + fi + fi + + docker compose exec -T postgres psql -U trails -d postgres -tAc \ + "SELECT 1 FROM pg_database WHERE datname='$DB'" \ + | grep -q 1 \ + || docker compose exec -T postgres createdb -U trails "$DB" + docker compose exec -T postgres psql -U trails -d "$DB" -c \ + "CREATE EXTENSION IF NOT EXISTS postgis" + + docker compose -f docker-compose.staging.yml -p "$PROJECT" --env-file "$ENV_FILE" pull journal + docker compose -f docker-compose.staging.yml -p "$PROJECT" --env-file "$ENV_FILE" run --rm journal npx drizzle-kit push --config /app/packages/db/drizzle.config.ts --force 2>&1 | tee /tmp/drizzle-push.log + if grep -q "Error:" /tmp/drizzle-push.log; then + echo "drizzle-kit push reported an error — failing the preview deploy" + exit 1 + fi + docker compose -f docker-compose.staging.yml -p "$PROJECT" --env-file "$ENV_FILE" up -d --remove-orphans journal + + docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile + + docker image prune -af --filter "until=1h" || true + + docker compose -f docker-compose.staging.yml -p "$PROJECT" --env-file "$ENV_FILE" ps + + - name: Find existing preview comment + uses: peter-evans/find-comment@v4 + id: find-comment + with: + issue-number: ${{ github.event.number }} + comment-author: "forgejo-actions[bot]" + body-includes: "" + + - name: Upsert preview comment on PR + uses: peter-evans/create-or-update-comment@v5 + with: + comment-id: ${{ steps.find-comment.outputs.comment-id }} + issue-number: ${{ github.event.number }} + edit-mode: replace + body: | + 🚀 **PR preview deployed** + + - **Journal:** https://${{ steps.ports.outputs.host }} + - **Planner (shared staging):** https://planner.staging.trails.cool + - **Database:** `${{ steps.ports.outputs.database }}` (separate from production / persistent staging) + - **Build:** [run ${{ github.run_id }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) · commit `${{ github.event.pull_request.head.sha }}` + + Updates automatically on push. Tears down when this PR closes. + + + teardown-preview: + name: Tear Down PR Preview + if: > + github.event_name == 'pull_request' && + (github.event.action == 'closed' || + (github.event.action == 'unlabeled' && + !(contains(github.event.pull_request.labels.*.name, 'preview') || + contains(github.event.pull_request.body, '')))) + runs-on: ubuntu-latest + steps: + - id: ports + name: Compute project + database name + run: | + PR=${{ github.event.number }} + echo "pr=$PR" >> "$GITHUB_OUTPUT" + echo "project=trails-pr-$PR" >> "$GITHUB_OUTPUT" + echo "database=trails_pr_$PR" >> "$GITHUB_OUTPUT" + + - uses: actions/checkout@v7 + + - name: Decrypt secrets (needed to satisfy compose env vars during down) + run: | + curl -sLO https://github.com/getsops/sops/releases/download/v3.9.4/sops-v3.9.4.linux.amd64 + chmod +x sops-v3.9.4.linux.amd64 + SOPS_AGE_KEY="${{ secrets.AGE_SECRET_KEY }}" ./sops-v3.9.4.linux.amd64 -d infrastructure/secrets.app.env > infrastructure/staging-pr-${{ steps.ports.outputs.pr }}.env + { + echo "DOMAIN=pr-${{ steps.ports.outputs.pr }}.staging.trails.cool" + echo "STAGING_DATABASE=${{ steps.ports.outputs.database }}" + echo "JOURNAL_HOST_PORT=$((3200 + 2 * ${{ steps.ports.outputs.pr }}))" + echo "PLANNER_HOST_PORT=$((3201 + 2 * ${{ steps.ports.outputs.pr }}))" + } >> infrastructure/staging-pr-${{ steps.ports.outputs.pr }}.env + + - name: Copy compose + env (teardown still needs the file) + uses: appleboy/scp-action@v1 + with: + host: ${{ secrets.DEPLOY_HOST }} + username: root + key: ${{ secrets.DEPLOY_SSH_KEY }} + source: "infrastructure/docker-compose.staging.yml,infrastructure/staging-pr-${{ steps.ports.outputs.pr }}.env" + target: /opt/trails-cool + strip_components: 1 + + - name: Tear down via SSH + uses: appleboy/ssh-action@v1 + env: + PR: ${{ steps.ports.outputs.pr }} + PROJECT: ${{ steps.ports.outputs.project }} + DB: ${{ steps.ports.outputs.database }} + with: + host: ${{ secrets.DEPLOY_HOST }} + username: root + key: ${{ secrets.DEPLOY_SSH_KEY }} + envs: PR,PROJECT,DB + script: | + set -euo pipefail + cd /opt/trails-cool + ENV_FILE="staging-pr-${PR}.env" + + docker compose -f docker-compose.staging.yml -p "$PROJECT" --env-file "$ENV_FILE" down --remove-orphans || true + + docker compose exec -T postgres dropdb -U trails --if-exists "$DB" || true + + rm -f "sites/pr-$PR.caddyfile" "$ENV_FILE" + docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile || true + + - name: Find existing preview comment + uses: peter-evans/find-comment@v4 + id: find-comment + with: + issue-number: ${{ github.event.number }} + comment-author: "forgejo-actions[bot]" + body-includes: "" + + - name: Update preview comment on close + if: steps.find-comment.outputs.comment-id + uses: peter-evans/create-or-update-comment@v5 + with: + comment-id: ${{ steps.find-comment.outputs.comment-id }} + edit-mode: replace + body: | + 🧹 **PR preview torn down**. + + Database `trails_pr_${{ steps.ports.outputs.pr }}` dropped, containers removed, Caddyfile snippet cleared. + + [Teardown run ${{ github.run_id }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) + diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index d59410b..e57a95f 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -1,15 +1,69 @@ name: CI -# Forgejo Actions port — proof workflow. Once green, the remaining jobs from -# .github/workflows/ci.yml (lint, test, build, e2e, visual, image-smoke, security) -# get ported here. NOTE: Forgejo uses .forgejo/workflows in preference to -# .github/workflows, so the GitHub copies do not double-run here. +# Ported from .github/workflows/ci.yml for Forgejo Actions. +# Differences from the GitHub version: +# - no `merge_group` trigger (Forgejo has no merge queue) +# - gitleaks action referenced by full GitHub URL (custom action, not on +# the Forgejo actions mirror) +# - the visual-diff PR comment posts via the Forgejo API instead of `gh` +# The `ci/forgejo-actions` push branch is temporary (lets this branch re-run +# CI pre-merge); drop it once merged to main. on: push: branches: [main, ci/forgejo-actions] pull_request: branches: [main] +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: + security: + name: Security Scan + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - name: Gitleaks + if: github.actor != 'dependabot[bot]' + uses: https://github.com/trails-cool/gitleaks-action@4cbc857b9cfa2a3297fe2be1078e196d30d1b424 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }} + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v6 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - name: Dependency audit + run: pnpm audit --audit-level=high + continue-on-error: true + + dockerfile-check: + name: Dockerfile Package Check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - run: bash scripts/check-dockerfiles.sh + + openspec: + name: OpenSpec Validate + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v6 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm openspec validate --all --strict --no-interactive + typecheck: name: Typecheck runs-on: ubuntu-latest @@ -22,3 +76,302 @@ jobs: cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm typecheck + + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v6 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm lint + + test: + name: Unit Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v6 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm test + + build: + name: Build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v6 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm build + + visual-tests: + name: Visual Tests + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + steps: + - uses: actions/checkout@v7 + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v6 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + + - name: Cache Playwright browsers + id: playwright-cache + uses: actions/cache@v6 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ hashFiles('pnpm-lock.yaml') }} + + - name: Install Playwright Chromium + if: steps.playwright-cache.outputs.cache-hit != 'true' + run: pnpm exec playwright install --with-deps chromium + + - name: Install Playwright deps only + if: steps.playwright-cache.outputs.cache-hit == 'true' + run: pnpm exec playwright install-deps chromium + + - name: Run visual regression tests + id: visual-tests + run: pnpm --filter @trails-cool/planner test:visual + + - name: Post diff comment on PR + if: failure() && steps.visual-tests.outcome == 'failure' && github.event_name == 'pull_request' + env: + TOKEN: ${{ github.token }} + run: | + diffs=$(find apps/planner/.vitest-attachments -name "*-diff-*.png" 2>/dev/null | sort) + if [ -z "$diffs" ]; then exit 0; fi + + artifact_url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + body="## Visual regression failures"$'\n\n' + body+="The following tests produced screenshot diffs:"$'\n\n' + for diff in $diffs; do + name=$(basename "$diff" | sed 's/-diff-chromium-[a-z]*\.png//' | sed 's/-/ /g') + body+="- \`$name\`"$'\n' + done + body+=$'\n'"**[Download the \`visual-snapshots-diff\` artifact]($artifact_url)** to inspect the diffs locally."$'\n\n' + body+="To update snapshots if the change is intentional:"$'\n' + body+="\`\`\`"$'\n' + body+="pnpm --filter @trails-cool/planner test:visual:update"$'\n' + body+="\`\`\`" + + # Forgejo API (GitHub-compatible issues/comments endpoint). + curl -sf -X POST \ + -H "Authorization: token $TOKEN" \ + -H "Content-Type: application/json" \ + "${{ github.api_url }}/repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments" \ + -d "$(jq -n --arg b "$body" '{body:$b}')" || true + + - name: Upload screenshots on failure + if: failure() + uses: actions/upload-artifact@v7 + with: + name: visual-snapshots-diff + path: apps/planner/.vitest-attachments/ + include-hidden-files: true + retention-days: 7 + + e2e: + name: E2E Tests + needs: build + runs-on: ubuntu-latest + env: + DATABASE_URL: postgres://trails:trails@localhost:5432/trails + steps: + - uses: actions/checkout@v7 + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v6 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + + - name: Cache BRouter segment + id: segment-cache + uses: actions/cache@v6 + with: + path: /tmp/brouter-segments + key: brouter-segment-E10_N50-v1.7.9 + + - name: Download Berlin segment + if: steps.segment-cache.outputs.cache-hit != 'true' + run: | + mkdir -p /tmp/brouter-segments + wget -q "https://brouter.de/brouter/segments4/E10_N50.rd5" -O /tmp/brouter-segments/E10_N50.rd5 + + - name: Pre-seed BRouter segment volume + run: | + docker volume create trails_brouter_segments + docker run --rm \ + -v /tmp/brouter-segments:/src:ro \ + -v trails_brouter_segments:/dst \ + alpine sh -c "cp /src/*.rd5 /dst/ && chmod a+r /dst/*.rd5" + + - name: Start services + run: docker compose -f docker-compose.dev.yml up -d --wait --build + env: + BROUTER_URL: http://localhost:17777 + + - name: Wait for BRouter routing + run: | + for i in $(seq 1 60); do + curl -s 'http://localhost:17777/brouter?lonlats=13.4,52.5|13.5,52.5&profile=trekking&format=geojson' 2>/dev/null | grep -q "FeatureCollection" && echo "BRouter ready" && break + [ "$i" = "60" ] && echo "BRouter not ready after 120s" && exit 1 + sleep 2 + done + + - name: Push database schema + run: pnpm db:push + + - name: Seed database + run: pnpm db:seed + + - name: Run integration tests + run: pnpm --filter @trails-cool/journal exec vitest run --no-file-parallelism --reporter=default app/lib/explore.integration.test.ts app/lib/follow.integration.test.ts app/lib/demo-bot.integration.test.ts app/lib/notifications.integration.test.ts app/jobs/notifications-fanout.integration.test.ts + env: + EXPLORE_INTEGRATION: "1" + FOLLOW_INTEGRATION: "1" + DEMO_BOT_INTEGRATION: "1" + NOTIFICATIONS_INTEGRATION: "1" + + - name: Cache Playwright browsers + id: playwright-cache + uses: actions/cache@v6 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ hashFiles('pnpm-lock.yaml') }} + + - name: Install Playwright + if: steps.playwright-cache.outputs.cache-hit != 'true' + run: pnpm exec playwright install --with-deps chromium + + - name: Install Playwright deps only + if: steps.playwright-cache.outputs.cache-hit == 'true' + run: pnpm exec playwright install-deps chromium + + - name: Build for production + run: pnpm build + env: + VITE_SENTRY_ENVIRONMENT: ci + + - name: Run E2E tests + run: pnpm test:e2e + env: + BROUTER_URL: http://localhost:17777 + E2E: "true" + INTEGRATION_SECRET: ${{ secrets.INTEGRATION_SECRET }} + + - name: Playwright job summary + if: ${{ !cancelled() }} + run: | + if [ -f playwright-results.json ]; then + node -e " + const r = require('./playwright-results.json'); + const s = r.stats; + const dur = (s.duration / 1000).toFixed(1); + let md = '## Playwright E2E Results\n\n'; + md += '| Status | Count |\n|--------|-------|\n'; + md += '| :white_check_mark: Passed | ' + s.expected + ' |\n'; + if (s.unexpected > 0) md += '| :x: Failed | ' + s.unexpected + ' |\n'; + if (s.flaky > 0) md += '| :warning: Flaky | ' + s.flaky + ' |\n'; + if (s.skipped > 0) md += '| :fast_forward: Skipped | ' + s.skipped + ' |\n'; + md += '| :stopwatch: Duration | ' + dur + 's |\n\n'; + for (const file of r.suites) { + for (const describe of (file.suites || [])) { + md += '### ' + describe.title + '\n\n'; + for (const spec of (describe.specs || [])) { + const icon = spec.ok ? ':white_check_mark:' : ':x:'; + const t = spec.tests?.[0]?.results?.[0]?.duration; + md += '- ' + icon + ' ' + spec.title + (t ? ' (' + t + 'ms)' : '') + '\n'; + } + md += '\n'; + } + } + require('fs').appendFileSync(process.env.GITHUB_STEP_SUMMARY, md); + " + fi + + - uses: actions/upload-artifact@v7 + if: ${{ !cancelled() }} + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 + + journal-image-smoke: + name: Journal Image Smoke Test + runs-on: ubuntu-latest + services: + postgres: + image: imresamu/postgis:16-3.4 + env: + POSTGRES_USER: trails + POSTGRES_PASSWORD: trails + POSTGRES_DB: trails + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U trails" + --health-interval 5s + --health-timeout 5s + --health-retries 20 + env: + DATABASE_URL: postgres://trails:trails@localhost:5432/trails + steps: + - uses: actions/checkout@v7 + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v6 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + + - name: Push database schema + run: pnpm db:push + + - name: Build journal runtime image + run: docker build --target runtime -f apps/journal/Dockerfile -t journal-smoke . + + - name: Boot image and wait for healthy + run: | + docker run -d --name journal-smoke --network host \ + -e NODE_ENV=production -e E2E=true \ + -e DATABASE_URL="$DATABASE_URL" \ + journal-smoke + code=000 + for i in $(seq 1 30); do + code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 3 http://localhost:3000/api/health || echo 000) + echo "attempt $i: /api/health -> $code" + [ "$code" = "200" ] && break + if [ "$(docker inspect -f '{{.State.Running}}' journal-smoke 2>/dev/null)" != "true" ]; then + echo "::error::journal container exited during boot" + break + fi + sleep 2 + done + if [ "$code" != "200" ]; then + echo "::error::journal production image failed to boot healthy (see logs below)" + docker logs journal-smoke 2>&1 || true + exit 1 + fi + echo "journal production image booted healthy" + + - name: Container logs + if: always() + run: docker logs journal-smoke 2>&1 | tail -40 || true