name: Hetzner Deploy (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
- 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)"
- 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: Deploy
id: deploy
uses: appleboy/ssh-action@v1.2.0
with:
host: ${{ secrets.HETZNER_HOST }}
username: ${{ secrets.HETZNER_USER }}
key: ${{ secrets.HETZNER_SSH_KEY }}
command_timeout: 8m
script_stop: true
script: |
set -euo pipefail
cd /opt/gluecron
git fetch --prune origin main
git reset --hard origin/main
new_sha=$(git rev-parse HEAD)
echo "Deploying SHA: $new_sha"
BUN=/root/.bun/bin/bun
CACHE_DIR=/opt/gluecron/.cache
HASH_FILE=$CACHE_DIR/bun-lockfile-hash
mkdir -p "$CACHE_DIR"
if [ -f bun.lock ]; then
new_hash=$(sha256sum bun.lock | awk '{print $1}')
else
new_hash="no-lockfile"
fi
old_hash=""
if [ -f "$HASH_FILE" ]; then
old_hash=$(cat "$HASH_FILE")
fi
if [ "$new_hash" = "$old_hash" ] && [ -d node_modules ]; then
echo "==> bun install: SKIP (lockfile unchanged: $new_hash)"
else
echo "==> bun install: hash changed ($old_hash -> $new_hash) — installing"
"$BUN" install --frozen-lockfile
echo "$new_hash" > "$HASH_FILE"
fi
mkdir -p .next
COMPILED=.next/gluecron-server
COMPILED_TMP=.next/gluecron-server.new
if "$BUN" build --compile --outfile "$COMPILED_TMP" src/index.ts; then
mv -f "$COMPILED_TMP" "$COMPILED"
chmod +x "$COMPILED"
EXEC_START="/opt/gluecron/.next/gluecron-server"
echo "==> compiled binary ready: $COMPILED"
else
echo "WARN: bun build --compile failed — falling back to bun run"
rm -f "$COMPILED_TMP"
EXEC_START="$BUN run src/index.ts"
fi
UNIT=/etc/systemd/system/gluecron.service
DESIRED=$(cat <<UNIT_EOF
[Unit]
Description=Gluecron — AI-native code intelligence platform
After=network-online.target postgresql.service
Wants=network-online.target
[Service]
Type=notify
NotifyAccess=main
User=root
WorkingDirectory=/opt/gluecron
EnvironmentFile=/etc/gluecron.env
ExecStart=$EXEC_START
Restart=always
RestartSec=5
TimeoutStartSec=30
StandardOutput=journal
StandardError=journal
SyslogIdentifier=gluecron
LimitNOFILE=65536
[Install]
WantedBy=multi-user.target
UNIT_EOF
)
DESIRED=$(printf '%s\n' "$DESIRED" | sed 's/^ //')
need_rewrite=1
if [ -f "$UNIT" ] && diff -q <(printf '%s\n' "$DESIRED") "$UNIT" >/dev/null 2>&1; then
need_rewrite=0
fi
if [ "$need_rewrite" = "1" ]; then
echo "==> rewriting $UNIT (Type=notify, ExecStart=$EXEC_START)"
printf '%s\n' "$DESIRED" > "$UNIT"
systemctl daemon-reload
else
echo "==> $UNIT already matches desired state — skipping daemon-reload"
fi
set -a; source /etc/gluecron.env; set +a
"$BUN" run src/db/migrate.ts || echo "WARN: migrate failed (may be already-applied)"
echo "==> systemctl restart gluecron (blocks on sd_notify READY=1)"
systemctl restart gluecron
echo "==> restart returned — gluecron signalled ready"
- 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: Rollback on failure
if: failure() && steps.smoke.conclusion == 'failure' && github.event_name == 'push'
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)
echo "Rolling back to $prev"
git reset --hard "$prev"
systemctl restart gluecron
sleep 5
systemctl status gluecron --no-pager | head -20 || true
- 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)'
- 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
cat > /tmp/prompt.json <<EOF
{
"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: $COMMIT_SHA\nMessage: $COMMIT_MSG\n\nDeploy logs:\n$DEPLOY_LOGS"
}]
}
EOF
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
- 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
- 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
- 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 }}
run: |
DUR_MS=$(( ( $(date +%s) - START_EPOCH ) * 1000 ))
# First 1 KB of diagnostics — keeps the JSON small and the DB row sane.
ERR_TEXT=$(printf '%s' "${DIAG:-deploy failed; 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)"
|