Prepare deployment and Forgejo CI
Some checks failed
CI / check (push) Failing after 8s

This commit is contained in:
kacper 2026-04-14 20:17:29 -04:00
parent 51706d2d11
commit 853e99ca5f
21 changed files with 1402 additions and 77 deletions

173
scripts/bootstrap_ci_clone_key.py Executable file
View file

@ -0,0 +1,173 @@
#!/usr/bin/env python3
from __future__ import annotations
import json
import os
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
from urllib.error import HTTPError
from urllib.parse import quote
from urllib.request import Request, urlopen
DEFAULT_FORGEJO_BASE_URL = "https://aksal.cloud"
DEFAULT_REPO = "Robot-U/robot-u-site"
DEFAULT_KEY_TITLE = "robot-u-site-actions-clone"
DEFAULT_SECRET_NAME = "CI_REPO_SSH_KEY"
def main() -> int:
root_dir = Path(__file__).resolve().parents[1]
_load_env_file(root_dir / ".env")
_load_env_file(root_dir / ".env.local")
token = os.getenv("FORGEJO_API_TOKEN") or os.getenv("FORGEJO_TOKEN")
if not token:
print(
"Set FORGEJO_API_TOKEN to a Forgejo token that can manage repo keys/secrets.",
file=sys.stderr,
)
return 1
base_url = (os.getenv("FORGEJO_BASE_URL") or DEFAULT_FORGEJO_BASE_URL).rstrip("/")
repo = os.getenv("FORGEJO_REPO") or DEFAULT_REPO
key_title = os.getenv("CI_CLONE_KEY_TITLE") or DEFAULT_KEY_TITLE
secret_name = os.getenv("CI_CLONE_SECRET_NAME") or DEFAULT_SECRET_NAME
owner, repo_name = _repo_parts(repo)
ssh_keygen = shutil.which("ssh-keygen")
if not ssh_keygen:
print("ssh-keygen is required.", file=sys.stderr)
return 1
with tempfile.TemporaryDirectory(prefix="robot-u-ci-key.") as temp_dir:
key_path = Path(temp_dir) / "id_ed25519"
subprocess.run(
[
ssh_keygen,
"-t",
"ed25519",
"-N",
"",
"-C",
key_title,
"-f",
str(key_path),
],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
public_key = key_path.with_suffix(".pub").read_text().strip()
private_key = key_path.read_text()
deploy_key = _create_deploy_key(
base_url,
token,
owner,
repo_name,
key_title,
public_key,
)
_put_actions_secret(base_url, token, owner, repo_name, secret_name, private_key)
print(f"Added read-only deploy key {deploy_key.get('id', '(unknown id)')} to {repo}.")
print(f"Updated repository Actions secret {secret_name}.")
print("Private key material was only held in memory and a temporary directory.")
return 0
def _load_env_file(path: Path) -> None:
if not path.exists():
return
for raw_line in path.read_text().splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
key = key.strip().removeprefix("export ").strip()
value = value.strip().strip('"').strip("'")
os.environ.setdefault(key, value)
def _repo_parts(repo: str) -> tuple[str, str]:
parts = repo.split("/", 1)
if len(parts) != 2 or not parts[0] or not parts[1]:
raise SystemExit("FORGEJO_REPO must use owner/repo format.")
return parts[0], parts[1]
def _create_deploy_key(
base_url: str,
token: str,
owner: str,
repo: str,
title: str,
public_key: str,
) -> dict[str, object]:
path = f"/api/v1/repos/{quote(owner)}/{quote(repo)}/keys"
payload = {
"title": title,
"key": public_key,
"read_only": True,
}
return _request_json(base_url, token, "POST", path, payload)
def _put_actions_secret(
base_url: str,
token: str,
owner: str,
repo: str,
name: str,
value: str,
) -> None:
path = f"/api/v1/repos/{quote(owner)}/{quote(repo)}/actions/secrets/{quote(name)}"
_request_json(base_url, token, "PUT", path, {"data": value})
def _request_json(
base_url: str,
token: str,
method: str,
path: str,
payload: dict[str, object],
) -> dict[str, object]:
data = json.dumps(payload).encode("utf-8")
request = Request(
f"{base_url}{path}",
data=data,
method=method,
headers={
"Authorization": f"token {token}",
"Content-Type": "application/json",
"Accept": "application/json",
},
)
try:
with urlopen(request, timeout=20) as response:
if response.status == 204:
return {}
raw_body = response.read()
except HTTPError as error:
detail = error.read().decode("utf-8", errors="replace")
print(f"Forgejo API returned {error.code}: {detail}", file=sys.stderr)
raise SystemExit(1) from error
if not raw_body:
return {}
decoded = json.loads(raw_body.decode("utf-8"))
if isinstance(decoded, dict):
return decoded
return {}
if __name__ == "__main__":
raise SystemExit(main())

140
scripts/check_deploy_config.py Executable file
View file

@ -0,0 +1,140 @@
#!/usr/bin/env python3
from __future__ import annotations
import os
import sys
from pathlib import Path
from urllib.parse import urlparse
PLACEHOLDER_VALUES = {
"",
"replace-with-a-random-32-byte-or-longer-secret",
"your-forgejo-oauth-client-id",
"your-forgejo-oauth-client-secret",
}
def main() -> int:
root_dir = Path(__file__).resolve().parents[1]
_load_env_file(root_dir / ".env")
_load_env_file(root_dir / ".env.local")
errors: list[str] = []
warnings: list[str] = []
app_base_url = _required_env("APP_BASE_URL", errors)
parsed_app_url = urlparse(app_base_url)
if app_base_url and parsed_app_url.scheme not in {"http", "https"}:
errors.append("APP_BASE_URL must start with http:// or https://.")
if parsed_app_url.scheme == "http":
warnings.append("APP_BASE_URL uses http://. Use https:// for public deployment.")
auth_secret = _required_env("AUTH_SECRET_KEY", errors)
if auth_secret in PLACEHOLDER_VALUES or len(auth_secret) < 32:
errors.append("AUTH_SECRET_KEY must be a real random secret at least 32 characters long.")
auth_cookie_secure = _env_bool("AUTH_COOKIE_SECURE")
if parsed_app_url.scheme == "https" and not auth_cookie_secure:
errors.append("AUTH_COOKIE_SECURE=true is required when APP_BASE_URL uses https://.")
if parsed_app_url.scheme == "http" and auth_cookie_secure:
warnings.append("AUTH_COOKIE_SECURE=true will prevent cookies over plain HTTP.")
_required_env("FORGEJO_BASE_URL", errors)
_required_env("FORGEJO_OAUTH_CLIENT_ID", errors)
_required_env("FORGEJO_OAUTH_CLIENT_SECRET", errors)
general_repo = _required_env("FORGEJO_GENERAL_DISCUSSION_REPO", errors)
if general_repo and len(general_repo.split("/", 1)) != 2:
errors.append("FORGEJO_GENERAL_DISCUSSION_REPO must use owner/repo format.")
cors_origins = _csv_env("CORS_ALLOW_ORIGINS")
if not cors_origins:
warnings.append("CORS_ALLOW_ORIGINS is not set. The app will default to APP_BASE_URL.")
elif "*" in cors_origins:
warnings.append("CORS_ALLOW_ORIGINS includes '*'. Avoid that for public deployment.")
if not os.getenv("FORGEJO_WEBHOOK_SECRET"):
warnings.append(
"FORGEJO_WEBHOOK_SECRET is not set. Webhook cache invalidation is unauthenticated."
)
_positive_number_env("FORGEJO_REPO_SCAN_LIMIT", errors)
_positive_number_env("FORGEJO_RECENT_ISSUE_LIMIT", errors)
_positive_number_env("CALENDAR_EVENT_LIMIT", errors)
_non_negative_number_env("FORGEJO_CACHE_TTL_SECONDS", errors)
_positive_number_env("FORGEJO_REQUEST_TIMEOUT_SECONDS", errors)
for warning in warnings:
print(f"WARNING: {warning}", file=sys.stderr)
if errors:
for error in errors:
print(f"ERROR: {error}", file=sys.stderr)
return 1
print("Deployment configuration looks usable.")
return 0
def _load_env_file(path: Path) -> None:
if not path.exists():
return
for raw_line in path.read_text().splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
key = key.strip().removeprefix("export ").strip()
value = value.strip().strip('"').strip("'")
os.environ.setdefault(key, value)
def _required_env(name: str, errors: list[str]) -> str:
value = os.getenv(name, "").strip()
if value in PLACEHOLDER_VALUES:
errors.append(f"{name} is required.")
return value
def _env_bool(name: str) -> bool:
return os.getenv(name, "").strip().lower() in {"1", "true", "yes", "on"}
def _csv_env(name: str) -> tuple[str, ...]:
value = os.getenv(name, "").strip()
if not value:
return ()
return tuple(entry.strip() for entry in value.replace("\n", ",").split(",") if entry.strip())
def _positive_number_env(name: str, errors: list[str]) -> None:
value = os.getenv(name, "").strip()
if not value:
return
try:
parsed_value = float(value)
except ValueError:
errors.append(f"{name} must be numeric.")
return
if parsed_value <= 0:
errors.append(f"{name} must be greater than zero.")
def _non_negative_number_env(name: str, errors: list[str]) -> None:
value = os.getenv(name, "").strip()
if not value:
return
try:
parsed_value = float(value)
except ValueError:
errors.append(f"{name} must be numeric.")
return
if parsed_value < 0:
errors.append(f"{name} must be zero or greater.")
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -11,6 +11,8 @@ python_files=(
"live_prototype.py"
"prototype_cache.py"
"settings.py"
"scripts/bootstrap_ci_clone_key.py"
"scripts/check_deploy_config.py"
"update_events.py"
"tests"
)
@ -55,7 +57,7 @@ run_check \
run_check \
"Vulture" \
uv run --with "vulture>=2.15,<3.0.0" \
vulture app.py auth.py calendar_feeds.py forgejo_client.py live_prototype.py prototype_cache.py settings.py update_events.py tests --min-confidence 80
vulture app.py auth.py calendar_feeds.py forgejo_client.py live_prototype.py prototype_cache.py settings.py scripts/bootstrap_ci_clone_key.py scripts/check_deploy_config.py update_events.py tests --min-confidence 80
run_check \
"Backend Tests" \
"${python_cmd[@]}" -m unittest discover -s tests -p "test_*.py"

18
scripts/run_prod.sh Executable file
View file

@ -0,0 +1,18 @@
#!/usr/bin/env bash
set -euo pipefail
root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "${root_dir}"
host="${HOST:-0.0.0.0}"
port="${PORT:-8000}"
if [[ ! -f "${root_dir}/frontend/dist/index.html" ]]; then
echo "frontend/dist/index.html is missing. Build the frontend before starting production." >&2
exit 1
fi
exec python3 -m uvicorn app:app \
--host "${host}" \
--port "${port}" \
--proxy-headers