Skip to content

Commit 188faa9

Browse files
committed
Add Hetzner deploy pipeline and rework web UI/share routes
1 parent 4872790 commit 188faa9

7 files changed

Lines changed: 422 additions & 28 deletions

File tree

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
name: Deploy Hetzner
2+
3+
on:
4+
push:
5+
branches: [master]
6+
workflow_dispatch:
7+
8+
concurrency:
9+
group: deploy-hetzner-recon
10+
cancel-in-progress: true
11+
12+
jobs:
13+
deploy:
14+
runs-on: ubuntu-latest
15+
env:
16+
APP_DIR: /root/github-roast
17+
APP_PORT: "8011"
18+
SERVICE_NAME: github-roast
19+
DOMAIN_NAME: gh-roast.deeprecon.app
20+
CADDY_REPO_DIR: /root/recon
21+
CADDY_CONTAINER: recon-caddy-1
22+
steps:
23+
- name: Checkout
24+
uses: actions/checkout@v4
25+
26+
- name: Configure SSH key
27+
env:
28+
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
29+
SSH_HOST: ${{ secrets.HETZNER_HOST }}
30+
run: |
31+
test -n "$SSH_PRIVATE_KEY"
32+
test -n "$SSH_HOST"
33+
mkdir -p ~/.ssh
34+
chmod 700 ~/.ssh
35+
printf '%s\n' "$SSH_PRIVATE_KEY" | tr -d '\r' > ~/.ssh/id_ed25519
36+
chmod 600 ~/.ssh/id_ed25519
37+
ssh-keyscan -H "$SSH_HOST" >> ~/.ssh/known_hosts
38+
39+
- name: Sync app to server
40+
env:
41+
SSH_HOST: ${{ secrets.HETZNER_HOST }}
42+
SSH_USER: ${{ secrets.HETZNER_USER }}
43+
run: |
44+
test -n "$SSH_USER"
45+
rsync -az --delete \
46+
--exclude '.git' \
47+
--exclude '.github' \
48+
--exclude '.venv' \
49+
--exclude '__pycache__' \
50+
./ "$SSH_USER@$SSH_HOST:$APP_DIR/"
51+
52+
- name: Run remote deploy
53+
env:
54+
SSH_HOST: ${{ secrets.HETZNER_HOST }}
55+
SSH_USER: ${{ secrets.HETZNER_USER }}
56+
CLIPROXY_BASE_URL: ${{ secrets.CLIPROXY_BASE_URL }}
57+
CLIPROXY_API_KEY: ${{ secrets.CLIPROXY_API_KEY }}
58+
DEPLOY_GH_TOKEN: ${{ secrets.DEPLOY_GH_TOKEN }}
59+
run: |
60+
EFFECTIVE_CLIPROXY_BASE_URL="${CLIPROXY_BASE_URL:-http://127.0.0.1:8317}"
61+
ssh "$SSH_USER@$SSH_HOST" \
62+
"APP_DIR='$APP_DIR' SERVICE_NAME='$SERVICE_NAME' DOMAIN_NAME='$DOMAIN_NAME' APP_PORT='$APP_PORT' CADDY_REPO_DIR='$CADDY_REPO_DIR' CADDY_CONTAINER='$CADDY_CONTAINER' CLIPROXY_BASE_URL='$EFFECTIVE_CLIPROXY_BASE_URL' CLIPROXY_API_KEY='$CLIPROXY_API_KEY' GH_TOKEN='$DEPLOY_GH_TOKEN' bash '$APP_DIR/scripts/deploy_remote.sh'"

DEPLOY_DEFAULTS.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# Deploy Defaults (Operator Memory)
2+
3+
These are the standing defaults for this repo.
4+
5+
- Target host alias: `hetzner-recon`
6+
- Deploy trigger branch: `master`
7+
- App domain: `gh-roast.deeprecon.app`
8+
- Server app path: `/root/github-roast`
9+
- Service name: `github-roast`
10+
- App bind: `0.0.0.0:8011`
11+
- LLM backend on server: `CLIProxyAPI` (not relay)
12+
- Default CLIProxyAPI URL: `http://127.0.0.1:8317`
13+
- Caddy config source: `/root/recon/Caddyfile` in container `recon-caddy-1`
14+
15+
## GitHub Actions secret convention
16+
17+
Use the same naming convention as `deeprecon`:
18+
19+
- `HETZNER_HOST`
20+
- `HETZNER_USER`
21+
- `SSH_PRIVATE_KEY`
22+
- `CLIPROXY_API_KEY`
23+
- Optional: `CLIPROXY_BASE_URL`
24+
- Optional: `DEPLOY_GH_TOKEN`
25+
26+
If these defaults change, update this file and the deploy workflow together.

README.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,45 @@ gh auth login # if not already authenticated
9494
github-roast youruser
9595
```
9696

97+
## Deploy to Hetzner (`hetzner-recon`)
98+
99+
This repo includes a production deploy workflow: `.github/workflows/deploy-hetzner.yml`.
100+
101+
It deploys on every push to `master` and:
102+
103+
1. SSHes into `hetzner-recon`
104+
2. Rsyncs the repo to `/root/github-roast`
105+
3. Runs `scripts/deploy_remote.sh` to:
106+
- create/update Python venv and install runtime deps
107+
- update `.env.production` with CLIProxyAPI runtime (`ANTHROPIC_BASE_URL`)
108+
- restart a `github-roast` systemd service on `0.0.0.0:8011`
109+
- patch `/root/recon/Caddyfile` and reload `recon-caddy-1`
110+
111+
### GitHub Actions secrets
112+
113+
Set these repository secrets:
114+
115+
- `HETZNER_HOST` — SSH host (IP or DNS name)
116+
- `HETZNER_USER` — SSH user
117+
- `SSH_PRIVATE_KEY` — private key used by Actions
118+
- `CLIPROXY_API_KEY` — CLIProxyAPI key for runtime LLM calls
119+
- `CLIPROXY_BASE_URL` (optional) — defaults to `http://127.0.0.1:8317`
120+
- `DEPLOY_GH_TOKEN` (optional) — token for `gh` CLI runtime auth on server
121+
122+
### One-time DNS setup
123+
124+
Create an `A` record:
125+
126+
- `gh-roast.deeprecon.app` -> `<hetzner-recon public IPv4>`
127+
128+
### Host prerequisites
129+
130+
On the server, ensure:
131+
132+
- `uv` is installed
133+
- `caddy` is installed and running (for TLS + reverse proxy)
134+
- deploy user has sudo (passwordless for systemd/Caddy reload)
135+
97136
## License
98137

99138
Apache 2.0 — see [LICENSE](LICENSE)

analyzer.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,16 @@
77

88
import asyncio
99
import json
10+
import os
1011
import re
1112
import time
1213
from datetime import datetime, timezone
1314
from typing import Any
1415

1516
import anthropic
1617

17-
RELAY_URL = "http://localhost:18082"
18+
RELAY_URL = os.getenv("ANTHROPIC_BASE_URL", "http://localhost:18082")
19+
ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY", "unused")
1820
MODEL = "claude-sonnet-4-6"
1921
MAINTAINERS = {"npow", "romain-intel", "saikonen", "mt-ob", "dpoznik", "dependabot[bot]"}
2022

@@ -23,7 +25,7 @@
2325

2426

2527
def _llm_client() -> anthropic.Anthropic:
26-
return anthropic.Anthropic(base_url=RELAY_URL, api_key="unused")
28+
return anthropic.Anthropic(base_url=RELAY_URL, api_key=ANTHROPIC_API_KEY)
2729

2830

2931
def _is_retryable(exc: Exception) -> bool:

db.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,44 @@ def _get_job_sync(self, job_id: str) -> dict | None:
109109
async def get_job(self, job_id: str) -> dict | None:
110110
return await asyncio.get_event_loop().run_in_executor(None, self._get_job_sync, job_id)
111111

112+
def _list_recent_jobs_sync(self, limit: int = 20) -> list[dict]:
113+
rows = self._conn().execute(
114+
"SELECT id, type, status, input, created_at, updated_at FROM jobs "
115+
"ORDER BY created_at DESC LIMIT ?",
116+
(limit,),
117+
).fetchall()
118+
out: list[dict] = []
119+
for row in rows:
120+
d = dict(row)
121+
d["input"] = json.loads(d["input"]) if d["input"] else {}
122+
out.append(d)
123+
return out
124+
125+
async def list_recent_jobs(self, limit: int = 20) -> list[dict]:
126+
return await asyncio.get_event_loop().run_in_executor(None, self._list_recent_jobs_sync, limit)
127+
128+
def _find_latest_single_job_sync(self, username: str, repo: str = "") -> dict | None:
129+
rows = self._conn().execute(
130+
"SELECT * FROM jobs WHERE type='single' ORDER BY updated_at DESC LIMIT 500"
131+
).fetchall()
132+
wanted_user = (username or "").strip().lower()
133+
wanted_repo = (repo or "").strip()
134+
for row in rows:
135+
d = dict(row)
136+
inp = json.loads(d["input"]) if d.get("input") else {}
137+
job_user = (inp.get("username") or "").strip().lower()
138+
job_repo = (inp.get("repo") or "").strip()
139+
if job_user == wanted_user and job_repo == wanted_repo:
140+
d["input"] = inp
141+
d["result"] = json.loads(d["result"]) if d.get("result") else None
142+
return d
143+
return None
144+
145+
async def find_latest_single_job(self, username: str, repo: str = "") -> dict | None:
146+
return await asyncio.get_event_loop().run_in_executor(
147+
None, self._find_latest_single_job_sync, username, repo
148+
)
149+
112150
# ── Cache ─────────────────────────────────────────────────────────────────
113151

114152
def _cache_get_sync(self, key: str) -> Any | None:

scripts/deploy_remote.sh

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
APP_DIR="${APP_DIR:-/root/github-roast}"
5+
SERVICE_NAME="${SERVICE_NAME:-github-roast}"
6+
DOMAIN_NAME="${DOMAIN_NAME:-gh-roast.deeprecon.app}"
7+
APP_PORT="${APP_PORT:-8011}"
8+
CLIPROXY_BASE_URL="${CLIPROXY_BASE_URL:-http://127.0.0.1:8317}"
9+
CLIPROXY_API_KEY="${CLIPROXY_API_KEY:-}"
10+
GH_TOKEN="${GH_TOKEN:-}"
11+
CADDY_REPO_DIR="${CADDY_REPO_DIR:-/root/recon}"
12+
CADDY_CONTAINER="${CADDY_CONTAINER:-recon-caddy-1}"
13+
14+
cd "$APP_DIR"
15+
16+
python3 -m venv .venv
17+
. .venv/bin/activate
18+
pip install --upgrade pip
19+
pip install fastapi uvicorn anthropic httpx python-multipart
20+
21+
cat > .env.production <<ENV
22+
ANTHROPIC_BASE_URL=$CLIPROXY_BASE_URL
23+
ENV
24+
25+
if [[ -n "$CLIPROXY_API_KEY" ]]; then
26+
echo "ANTHROPIC_API_KEY=$CLIPROXY_API_KEY" >> .env.production
27+
fi
28+
29+
if [[ -n "$GH_TOKEN" ]]; then
30+
echo "GH_TOKEN=$GH_TOKEN" >> .env.production
31+
fi
32+
33+
cat > "/etc/systemd/system/${SERVICE_NAME}.service" <<SERVICE
34+
[Unit]
35+
Description=GitHub Roast FastAPI service
36+
After=network.target
37+
38+
[Service]
39+
Type=simple
40+
WorkingDirectory=$APP_DIR
41+
EnvironmentFile=$APP_DIR/.env.production
42+
ExecStart=$APP_DIR/.venv/bin/uvicorn webapp:app --host 0.0.0.0 --port $APP_PORT
43+
Restart=always
44+
RestartSec=3
45+
46+
[Install]
47+
WantedBy=multi-user.target
48+
SERVICE
49+
50+
systemctl daemon-reload
51+
systemctl enable "$SERVICE_NAME"
52+
systemctl restart "$SERVICE_NAME"
53+
54+
CADDYFILE="$CADDY_REPO_DIR/Caddyfile"
55+
if [[ -f "$CADDYFILE" ]]; then
56+
START_MARKER="# BEGIN ${SERVICE_NAME}"
57+
END_MARKER="# END ${SERVICE_NAME}"
58+
BLOCK="$START_MARKER
59+
$DOMAIN_NAME {
60+
\treverse_proxy 172.17.0.1:$APP_PORT
61+
}
62+
$END_MARKER"
63+
64+
TMP_FILE="$(mktemp)"
65+
awk -v s="$START_MARKER" -v e="$END_MARKER" '
66+
$0 == s {skip=1; next}
67+
$0 == e {skip=0; next}
68+
!skip {print}
69+
' "$CADDYFILE" > "$TMP_FILE"
70+
printf '\n%s\n' "$BLOCK" >> "$TMP_FILE"
71+
mv "$TMP_FILE" "$CADDYFILE"
72+
73+
docker exec "$CADDY_CONTAINER" caddy reload --config /etc/caddy/Caddyfile
74+
fi
75+
76+
echo "Deploy complete"

0 commit comments

Comments
 (0)