name: Hetzner Deploy (gluecron.com)

# Default-deny: scope GITHUB_TOKEN to read-only. The deploy uses an SSH key
# (HETZNER_SSH_KEY) to push to the box, not the GitHub token, so read-only
# is sufficient. Individual jobs may escalate if they need write access.
permissions: read-all

# Triggered on every push to main and on manual dispatch.
# Steps:
#   1. Capture the current SHA on the box (for rollback)
#   2. SSH in, git pull, run deploy-crontech.sh (which restarts systemd + reloads Caddy)
#   3. Smoke-test https://gluecron.com/healthz with retries
#   4. On smoke failure: roll back to the previous SHA and restart
#   5. On any failure: have Claude read the last 100 journal lines and post a one-paragraph
#      root-cause analysis to the workflow summary (and optionally a webhook)
#
# Secrets required:
#   HETZNER_HOST      — public IP/hostname of the box (e.g. 178.104.208.252)
#   HETZNER_USER      — ssh user (e.g. root)
#   HETZNER_SSH_KEY   — private deploy key (PEM/OpenSSH format)
#
# Optional:
#   ANTHROPIC_API_KEY      — enables AI failure-diagnosis step
#   DEPLOY_WEBHOOK_URL     — POSTed with JSON deploy status (Slack/Discord/anything)
#   DEPLOY_EVENT_TOKEN     — bearer used to POST deploy timeline events to
#                            ${APP_BASE_URL}/api/events/deploy/{started,finished}.
#                            Block N3: this makes the live site display ITS OWN
#                            deploy in the admin nav + at /admin/deploys.
#   APP_BASE_URL           — base URL of the running site (default https://gluecron.com).

on:
  push:
    branches: [main]
  workflow_dispatch: {}

concurrency:
  group: hetzner-deploy
  cancel-in-progress: false

jobs:
  deploy:
    name: Deploy gluecron.com
    runs-on: ubuntu-latest
    timeout-minutes: 12
    steps:
      - uses: actions/checkout@v4

      # ─── 0. Mark start time + notify the live site (Block N3) ────────────
      # The site has its own admin status pill + /admin/deploys timeline.
      # POSTing here makes the pill say "Deploying… 14s" the moment SSH
      # begins. `--fail` is intentionally NOT set: a 5xx here must never
      # block the deploy itself.
      - name: Record start time
        id: start
        run: |
          echo "epoch=$(date +%s)" >> $GITHUB_OUTPUT

      - name: Notify deploy started
        if: env.DEPLOY_EVENT_TOKEN != ''
        env:
          DEPLOY_EVENT_TOKEN: ${{ secrets.DEPLOY_EVENT_TOKEN }}
          APP_BASE_URL: ${{ secrets.APP_BASE_URL || 'https://gluecron.com' }}
        run: |
          curl --silent --show-error --max-time 10 \
            -X POST "$APP_BASE_URL/api/events/deploy/started" \
            -H "authorization: Bearer $DEPLOY_EVENT_TOKEN" \
            -H "content-type: application/json" \
            --data "{\"sha\":\"${{ github.sha }}\",\"run_id\":\"${{ github.run_id }}\",\"source\":\"hetzner-deploy\"}" \
            || echo "(deploy-started notify failed — continuing)"

      # ─── 1. Capture pre-deploy SHA so we can rollback ───────────────────
      # R2 — bracket every major step with notify-deploy-step calls so the
      # /admin/deploys modal can stream the workflow live. The composite
      # action is fire-and-forget — a 5xx never blocks the deploy.
      - name: Notify step — setup (in_progress)
        if: env.DEPLOY_EVENT_TOKEN != ''
        uses: ./.github/actions/notify-deploy-step
        with:
          step_name: setup
          status: in_progress
          app_base_url: ${{ secrets.APP_BASE_URL || 'https://gluecron.com' }}
          deploy_event_token: ${{ secrets.DEPLOY_EVENT_TOKEN }}

      - name: Capture pre-deploy SHA
        id: prev
        uses: appleboy/ssh-action@v1.2.0
        with:
          host: ${{ secrets.HETZNER_HOST }}
          username: ${{ secrets.HETZNER_USER }}
          key: ${{ secrets.HETZNER_SSH_KEY }}
          script_stop: true
          script: |
            cd /opt/gluecron
            sha=$(git rev-parse HEAD)
            echo "Previous SHA: $sha"
            echo "$sha" > /tmp/gluecron_prev_sha
            cat /tmp/gluecron_prev_sha

      - name: Notify step — setup (succeeded)
        if: success() && env.DEPLOY_EVENT_TOKEN != ''
        uses: ./.github/actions/notify-deploy-step
        with:
          step_name: setup
          status: succeeded
          app_base_url: ${{ secrets.APP_BASE_URL || 'https://gluecron.com' }}
          deploy_event_token: ${{ secrets.DEPLOY_EVENT_TOKEN }}

      - name: Notify step — setup (failed)
        if: failure() && env.DEPLOY_EVENT_TOKEN != ''
        uses: ./.github/actions/notify-deploy-step
        with:
          step_name: setup
          status: failed
          app_base_url: ${{ secrets.APP_BASE_URL || 'https://gluecron.com' }}
          deploy_event_token: ${{ secrets.DEPLOY_EVENT_TOKEN }}

      # ─── 2. Deploy: pull main, install/compile/restart ─────────────────
      # Block N2 — speed optimisation:
      #   (a) Cache `bun install` by hashing bun.lock; skip the walk when
      #       the lockfile is unchanged. Saves ~5-15s per "no deps changed"
      #       deploy.
      #   (b) Compile to a single Bun static binary at
      #       /opt/gluecron/.next/gluecron-server. Boot drops from ~500ms
      #       (cold ESM resolve) to <50ms. Compile itself takes ~3-5s.
      #   (c) Rewrite the systemd unit on first run if it lacks Type=notify
      #       (idempotent — diff-then-write, daemon-reload only on change).
      #       Falls back to `bun run src/index.ts` if the compiled binary is
      #       missing for any reason — the deploy MUST stay backward-
      #       compatible.
      #   (d) `systemctl restart` blocks until sd_notify(READY=1) fires
      #       (wired in src/lib/systemd-notify.ts), so we no longer rely on
      #       the curl-with-retries loop downstream.
      - name: Deploy
        id: deploy
        uses: appleboy/ssh-action@v1.2.0
        env:
          DEPLOY_EVENT_TOKEN: ${{ secrets.DEPLOY_EVENT_TOKEN }}
          APP_BASE_URL: ${{ secrets.APP_BASE_URL || 'https://gluecron.com' }}
          GH_RUN_ID: ${{ github.run_id }}
          GH_SHA: ${{ github.sha }}
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          GH_REPO: ${{ github.repository }}
        with:
          host: ${{ secrets.HETZNER_HOST }}
          username: ${{ secrets.HETZNER_USER }}
          key: ${{ secrets.HETZNER_SSH_KEY }}
          command_timeout: 8m
          script_stop: true
          envs: GH_TOKEN,GH_REPO,GH_SHA
          # FIRE-DRILL DEPLOY (2026-05-16):
          #
          # The original script (notify_step + cache hash + bun build
          # --compile + migration verifier + systemd unit rewrite + …)
          # was aborting silently between "Deploying SHA: …" and any
          # other output — under `set -euo pipefail`, no visible stderr,
          # no clue which line. The systemd unit had not been restarted
          # in 17 hours of failed deploys.
          #
          # This is the minimum viable replacement: trace every line
          # via `set -x` so failures are visible, skip the silent-fail
          # observability calls, do `git fetch + reset + bun install +
          # systemctl restart` and nothing else. We can re-add the
          # compile / migrate / cache-hash later — they were optional
          # optimisations, the service worker rip-out only needs the
          # new source code + a restart to land.
          script: |
            set -Eeuxo pipefail
            cd /opt/gluecron

            echo "=== STEP 1: repair git remote + fetch + reset ==="
            git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${GH_REPO}.git"
            git fetch --prune origin main
            git reset --hard origin/main
            new_sha=$(git rev-parse HEAD)
            echo "Deployed SHA on box: $new_sha"

            echo "=== STEP 2: bun install ==="
            BUN=/root/.bun/bin/bun
            "$BUN" install --frozen-lockfile

            echo "=== STEP 3: run db migrations ==="
            # Source /etc/gluecron.env so DATABASE_URL is available to
            # migrate.ts. Hard-fail if any migration errors — the previous
            # `|| echo WARN` swallowed migration failures and left the DB
            # out of sync with the schema, which produces 500s on every
            # page that SELECTs a column added by a missed migration.
            set -a
            source /etc/gluecron.env
            set +a
            "$BUN" run src/db/migrate.ts

            echo "=== STEP 4: pin BUILD_SHA in systemd drop-in ==="
            mkdir -p /etc/systemd/system/gluecron.service.d
            cat > /etc/systemd/system/gluecron.service.d/build-sha.conf <<EOF
            [Service]
            Environment="BUILD_SHA=$new_sha"
            Environment="BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)"
            EOF
            systemctl daemon-reload

            echo "=== STEP 5: restart gluecron systemd unit ==="
            systemctl restart gluecron
            echo "=== DONE: service restarted with $new_sha ==="

      # ─── 3. Smoke-test the deployed app on the box ──────────────────────
      # We SSH back in and curl localhost:3010/healthz directly. This tests
      # the EXACT instance we just deployed, independent of:
      #   - DNS state (gluecron.com may still point at an old box during a
      #     migration)
      #   - Caddy TLS state (cert may not be issued yet for a new domain)
      #   - external network reachability from GH runners
      # If you ALSO want a public-DNS smoke check, add a second step that
      # hits https://gluecron.com after this one succeeds.
      - name: Notify step — smoke-test (in_progress)
        if: env.DEPLOY_EVENT_TOKEN != ''
        uses: ./.github/actions/notify-deploy-step
        with:
          step_name: smoke-test
          status: in_progress
          app_base_url: ${{ secrets.APP_BASE_URL || 'https://gluecron.com' }}
          deploy_event_token: ${{ secrets.DEPLOY_EVENT_TOKEN }}

      - name: Smoke test (localhost on the box)
        id: smoke
        uses: appleboy/ssh-action@v1.2.0
        with:
          host: ${{ secrets.HETZNER_HOST }}
          username: ${{ secrets.HETZNER_USER }}
          key: ${{ secrets.HETZNER_SSH_KEY }}
          script_stop: true
          script: |
            # Block N2 — `systemctl restart` already blocked on
            # sd_notify(READY=1), so the FIRST curl should succeed. We keep a
            # short retry budget for paranoia: a brief delay between
            # systemd's READY ack and the HTTP listener becoming routable
            # via 127.0.0.1 is theoretically possible (unusual but cheap to
            # tolerate). 3 attempts × 2s = 6s ceiling instead of 8 × 6s = 48s.
            set +e
            for i in 1 2 3; do
              code=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:3010/healthz)
              echo "Attempt $i: /healthz -> $code"
              if [ "$code" = "200" ]; then
                echo "OK: gluecron is healthy on localhost:3010"
                curl -s http://localhost:3010/api/version || true
                exit 0
              fi
              sleep 2
            done
            echo "FAIL: /healthz did not return 200 after 6s"
            systemctl status gluecron --no-pager | head -10 || true
            journalctl -u gluecron -n 30 --no-pager || true
            exit 1

      - name: Notify step — smoke-test (succeeded)
        if: success() && env.DEPLOY_EVENT_TOKEN != ''
        uses: ./.github/actions/notify-deploy-step
        with:
          step_name: smoke-test
          status: succeeded
          app_base_url: ${{ secrets.APP_BASE_URL || 'https://gluecron.com' }}
          deploy_event_token: ${{ secrets.DEPLOY_EVENT_TOKEN }}

      - name: Notify step — smoke-test (failed)
        if: failure() && steps.smoke.conclusion == 'failure' && env.DEPLOY_EVENT_TOKEN != ''
        uses: ./.github/actions/notify-deploy-step
        with:
          step_name: smoke-test
          status: failed
          app_base_url: ${{ secrets.APP_BASE_URL || 'https://gluecron.com' }}
          deploy_event_token: ${{ secrets.DEPLOY_EVENT_TOKEN }}

      # ─── 3b. Full post-deploy smoke suite (Block S1+S3) ──────────────────
      # `/healthz` alone is NOT enough — it doesn't touch the DB schema, so
      # a broken migration leaves it green while every real page crashes
      # selecting columns that don't exist. The post-deploy-smoke script
      # hits 15 critical endpoints (login renders, /api/version, /demo,
      # /mcp, /sw.js, etc.) and verifies the LATEST drizzle/*.sql is in
      # the running process's reported migrations list. If ANY check
      # fails, the workflow auto-rolls back.
      - name: Notify step — full-smoke (in_progress)
        if: env.DEPLOY_EVENT_TOKEN != ''
        uses: ./.github/actions/notify-deploy-step
        with:
          step_name: full-smoke
          status: in_progress
          app_base_url: ${{ secrets.APP_BASE_URL || 'https://gluecron.com' }}
          deploy_event_token: ${{ secrets.DEPLOY_EVENT_TOKEN }}

      - name: Full post-deploy smoke suite
        id: full_smoke
        uses: appleboy/ssh-action@v1.2.0
        with:
          host: ${{ secrets.HETZNER_HOST }}
          username: ${{ secrets.HETZNER_USER }}
          key: ${{ secrets.HETZNER_SSH_KEY }}
          script_stop: true
          script: |
            set -e
            cd /opt/gluecron
            BUN=/root/.bun/bin/bun
            export GLUECRON_HOST="http://localhost:3010"
            echo "==> running 15-endpoint smoke suite against $GLUECRON_HOST"
            # The script exits 1 on endpoint failure, 2 on missing
            # migration. We treat both as fatal so the workflow rolls
            # back. stdout/stderr stream live to the GH Actions log.
            "$BUN" run scripts/post-deploy-smoke.ts

      - name: Notify step — full-smoke (succeeded)
        if: success() && env.DEPLOY_EVENT_TOKEN != ''
        uses: ./.github/actions/notify-deploy-step
        with:
          step_name: full-smoke
          status: succeeded
          app_base_url: ${{ secrets.APP_BASE_URL || 'https://gluecron.com' }}
          deploy_event_token: ${{ secrets.DEPLOY_EVENT_TOKEN }}

      - name: Notify step — full-smoke (failed)
        if: failure() && steps.full_smoke.conclusion == 'failure' && env.DEPLOY_EVENT_TOKEN != ''
        uses: ./.github/actions/notify-deploy-step
        with:
          step_name: full-smoke
          status: failed
          app_base_url: ${{ secrets.APP_BASE_URL || 'https://gluecron.com' }}
          deploy_event_token: ${{ secrets.DEPLOY_EVENT_TOKEN }}

      # ─── 4. Auto-rollback on smoke failure ──────────────────────────────
      # Only rolls back if the workflow was triggered by a normal push.
      # Manual workflow_dispatch runs SKIP rollback so the operator can
      # diagnose the new code on the box before reverting. This stops the
      # pathological case where rollback masks the real failure by reverting
      # to an already-broken previous SHA.
      #
      # S1 (2026-05-14): rollback now also fires when the FULL smoke suite
      # fails (steps.full_smoke), not just the basic /healthz curl. Recursion
      # cap: only ONE rollback attempt per workflow run (the `if:` guard
      # naturally enforces this — the only rollback step in the file).
      - name: Rollback on failure
        id: rollback
        if: failure() && github.event_name == 'push' && (steps.smoke.conclusion == 'failure' || steps.full_smoke.conclusion == 'failure')
        uses: appleboy/ssh-action@v1.2.0
        with:
          host: ${{ secrets.HETZNER_HOST }}
          username: ${{ secrets.HETZNER_USER }}
          key: ${{ secrets.HETZNER_SSH_KEY }}
          script_stop: false
          script: |
            cd /opt/gluecron
            prev=$(cat /tmp/gluecron_prev_sha 2>/dev/null || true)
            if [ -z "$prev" ]; then
              echo "ROLLBACK SKIPPED: no /tmp/gluecron_prev_sha — human intervention required"
              exit 1
            fi
            echo "ROLLED BACK to $prev because post-deploy smoke failed"
            git reset --hard "$prev"
            # Don't re-run migrations here: rolling back schema is
            # destructive and migrations are forward-only. We just put
            # the code back to where it was and restart. If a migration
            # is the reason the new code is incompatible with the old,
            # the operator must intervene manually.
            BUN=/root/.bun/bin/bun
            if [ -f bun.lock ] && [ -d node_modules ]; then
              echo "==> reusing existing node_modules (lockfile hash check skipped during rollback)"
            else
              "$BUN" install --frozen-lockfile || true
            fi
            systemctl restart gluecron
            # Verify the rollback target itself comes up green.
            sleep 3
            for i in 1 2 3; do
              code=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:3010/healthz)
              echo "Rollback healthz attempt $i: $code"
              if [ "$code" = "200" ]; then
                echo "OK: rolled-back instance is healthy"
                exit 0
              fi
              sleep 2
            done
            echo "WARN: rollback target ALSO failed /healthz — human intervention required"
            systemctl status gluecron --no-pager | head -20 || true
            exit 1

      # ─── 5. Failure diagnostics — captured into a file for summary + AI ──
      - name: Capture failure context
        if: failure()
        id: ctx
        uses: appleboy/ssh-action@v1.2.0
        with:
          host: ${{ secrets.HETZNER_HOST }}
          username: ${{ secrets.HETZNER_USER }}
          key: ${{ secrets.HETZNER_SSH_KEY }}
          script_stop: false
          script: |
            echo "===== systemd status ====="
            systemctl status gluecron --no-pager 2>&1 | head -40 || true
            echo ""
            echo "===== last 80 journal lines (gluecron) ====="
            journalctl -u gluecron -n 80 --no-pager --output=cat 2>&1 || true
            echo ""
            echo "===== caddy validate ====="
            caddy validate --config /etc/caddy/Caddyfile 2>&1 | head -20 || true
            echo ""
            echo "===== /healthz from inside box ====="
            curl -s -w "\nHTTP %{http_code}\n" http://localhost:3000/healthz 2>&1 || true
            echo ""
            echo "===== port 3000 listener ====="
            ss -tlnp 2>&1 | grep ':3000' || echo '(nothing listening on :3000)'

      # Always post the captured diagnostics to the workflow summary so the
      # owner can read what broke without SSH'ing or grepping log files.
      - name: Post diagnostics to summary
        if: failure() && steps.ctx.outputs.stdout != ''
        env:
          DIAG: ${{ steps.ctx.outputs.stdout }}
        run: |
          {
            echo "## ❌ Deploy failed — diagnostics"
            echo ""
            echo "**Commit:** \`${GITHUB_SHA:0:7}\` — ${GITHUB_EVENT_HEAD_COMMIT_MESSAGE:-${GITHUB_SHA:0:7}}"
            echo "**Run:** [#${GITHUB_RUN_ID}](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID})"
            echo ""
            echo '```'
            echo "$DIAG"
            echo '```'
          } >> $GITHUB_STEP_SUMMARY

      - name: AI root-cause analysis (Claude)
        if: failure() && env.ANTHROPIC_API_KEY != ''
        env:
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
          DEPLOY_LOGS: ${{ steps.ctx.outputs.stdout }}
          COMMIT_SHA: ${{ github.sha }}
          COMMIT_MSG: ${{ github.event.head_commit.message }}
        run: |
          set +e
          # Build the prompt. Use python3 to JSON-encode every untrusted input
          # (commit message, deploy logs, SHA) so backticks, $(…), quotes, or
          # newlines in a commit message cannot break out of the JSON string
          # or execute on the runner. The previous unquoted heredoc allowed
          # arbitrary command execution via crafted commit messages.
          python3 - <<'PY' > /tmp/prompt.json
          import json, os
          payload = {
              "model": "claude-haiku-4-5-20251001",
              "max_tokens": 600,
              "system": "You are a senior SRE diagnosing a failed deploy. Read the systemd status, journal, and curl output. In 1 short paragraph (under 100 words), identify the most likely root cause and the single fastest fix. Be direct, no preamble.",
              "messages": [{
                  "role": "user",
                  "content": "Commit: " + os.environ.get("COMMIT_SHA", "") +
                             "\nMessage: " + os.environ.get("COMMIT_MSG", "") +
                             "\n\nDeploy logs:\n" + os.environ.get("DEPLOY_LOGS", ""),
              }],
          }
          print(json.dumps(payload))
          PY
          response=$(curl -s https://api.anthropic.com/v1/messages \
            -H "x-api-key: $ANTHROPIC_API_KEY" \
            -H "anthropic-version: 2023-06-01" \
            -H "content-type: application/json" \
            --data @/tmp/prompt.json)
          analysis=$(echo "$response" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('content',[{}])[0].get('text','(no analysis)'))" 2>/dev/null)
          echo "## 🤖 AI Failure Analysis" >> $GITHUB_STEP_SUMMARY
          echo "" >> $GITHUB_STEP_SUMMARY
          echo "$analysis" >> $GITHUB_STEP_SUMMARY
          echo "" >> $GITHUB_STEP_SUMMARY
          echo "**Commit:** \`${COMMIT_SHA:0:7}\` — $COMMIT_MSG" >> $GITHUB_STEP_SUMMARY

      # ─── 6. Optional webhook notification ───────────────────────────────
      - name: Notify webhook
        if: always() && env.DEPLOY_WEBHOOK_URL != ''
        env:
          DEPLOY_WEBHOOK_URL: ${{ secrets.DEPLOY_WEBHOOK_URL }}
          STATUS: ${{ job.status }}
        run: |
          curl -s -X POST "$DEPLOY_WEBHOOK_URL" \
            -H "content-type: application/json" \
            --data "{\"status\":\"$STATUS\",\"target\":\"gluecron.com\",\"sha\":\"${{ github.sha }}\",\"run\":\"${{ github.run_id }}\"}" || true

      # ─── 7. Workflow summary on success ─────────────────────────────────
      - name: Success summary
        if: success()
        run: |
          echo "## ✅ Deploy succeeded" >> $GITHUB_STEP_SUMMARY
          echo "" >> $GITHUB_STEP_SUMMARY
          echo "- **Target:** https://gluecron.com" >> $GITHUB_STEP_SUMMARY
          echo "- **SHA:** \`${GITHUB_SHA:0:7}\`" >> $GITHUB_STEP_SUMMARY
          echo "- **Health:** /healthz → 200" >> $GITHUB_STEP_SUMMARY

      # ─── 8. Block N3 — POST deploy-finished event to the live site ───────
      # The site's admin status pill flips to "Deployed Ns ago" or
      # "Deploy failed Nm ago" the instant this lands. `if: always()` so we
      # always report final state (including failure); the inner `if:` flag
      # splits success vs failure for the payload body. We never `--fail` —
      # a 5xx must not retroactively break a deploy that actually succeeded.
      - name: Notify deploy finished (success)
        if: success() && env.DEPLOY_EVENT_TOKEN != ''
        env:
          DEPLOY_EVENT_TOKEN: ${{ secrets.DEPLOY_EVENT_TOKEN }}
          APP_BASE_URL: ${{ secrets.APP_BASE_URL || 'https://gluecron.com' }}
          START_EPOCH: ${{ steps.start.outputs.epoch }}
        run: |
          DUR_MS=$(( ( $(date +%s) - START_EPOCH ) * 1000 ))
          curl --silent --show-error --max-time 10 \
            -X POST "$APP_BASE_URL/api/events/deploy/finished" \
            -H "authorization: Bearer $DEPLOY_EVENT_TOKEN" \
            -H "content-type: application/json" \
            --data "{\"run_id\":\"${{ github.run_id }}\",\"sha\":\"${{ github.sha }}\",\"status\":\"succeeded\",\"duration_ms\":$DUR_MS}" \
            || echo "(deploy-finished[succeeded] notify failed — continuing)"

      - name: Notify deploy finished (failure)
        if: failure() && env.DEPLOY_EVENT_TOKEN != ''
        env:
          DEPLOY_EVENT_TOKEN: ${{ secrets.DEPLOY_EVENT_TOKEN }}
          APP_BASE_URL: ${{ secrets.APP_BASE_URL || 'https://gluecron.com' }}
          START_EPOCH: ${{ steps.start.outputs.epoch }}
          DIAG: ${{ steps.ctx.outputs.stdout }}
          ROLLBACK_OUTCOME: ${{ steps.rollback.outcome }}
          SMOKE_OUTCOME: ${{ steps.smoke.conclusion }}
          FULL_SMOKE_OUTCOME: ${{ steps.full_smoke.conclusion }}
        run: |
          DUR_MS=$(( ( $(date +%s) - START_EPOCH ) * 1000 ))
          # Build a short reason header. S1 (2026-05-14): the deploy-
          # finished payload now records WHICH smoke layer failed and
          # whether the rollback succeeded, so /admin/deploys shows a
          # red pill with the actual cause instead of "deploy failed".
          REASON_HEADER=""
          if [ "$FULL_SMOKE_OUTCOME" = "failure" ]; then
            REASON_HEADER="post-deploy smoke suite failed"
          elif [ "$SMOKE_OUTCOME" = "failure" ]; then
            REASON_HEADER="/healthz smoke failed"
          fi
          if [ -n "$ROLLBACK_OUTCOME" ] && [ "$ROLLBACK_OUTCOME" != "skipped" ]; then
            if [ "$ROLLBACK_OUTCOME" = "success" ]; then
              REASON_HEADER="ROLLED BACK — $REASON_HEADER"
            else
              REASON_HEADER="ROLLBACK FAILED — $REASON_HEADER — human intervention required"
            fi
          fi
          # First 1 KB of diagnostics — keeps the JSON small and the DB row sane.
          ERR_TEXT=$(printf '%s\n\n%s' "${REASON_HEADER:-deploy failed}" "${DIAG:-see workflow logs}" | head -c 1024)
          # jq -Rs '.' is the safest way to JSON-escape arbitrary multi-line text.
          if command -v jq >/dev/null 2>&1; then
            ERR_JSON=$(printf '%s' "$ERR_TEXT" | jq -Rs '.')
          else
            ERR_JSON=$(printf '%s' "$ERR_TEXT" | python3 -c "import sys,json;print(json.dumps(sys.stdin.read()))")
          fi
          curl --silent --show-error --max-time 10 \
            -X POST "$APP_BASE_URL/api/events/deploy/finished" \
            -H "authorization: Bearer $DEPLOY_EVENT_TOKEN" \
            -H "content-type: application/json" \
            --data "{\"run_id\":\"${{ github.run_id }}\",\"sha\":\"${{ github.sha }}\",\"status\":\"failed\",\"duration_ms\":$DUR_MS,\"error\":$ERR_JSON}" \
            || echo "(deploy-finished[failed] notify failed — continuing)"
