ci: deploy Hub admin SPA fleets via Gitea Actions

Add deploy_fleet_release.sh and a workflow that rolls immutable Hub
releases (including admin-web) to educraft-dev on push/PR and educraft
on main/tags, selecting silos by HUB_PUBLIC_BASE_URL middle domain.
This commit is contained in:
2026-07-15 00:14:22 +08:00
parent 1d2f4657ba
commit 7269480abb
3 changed files with 365 additions and 0 deletions
+126
View File
@@ -0,0 +1,126 @@
name: deploy admin (hub fleet)
# Rolls Hub releases that include the org-admin SPA (`admin-web` → registerStaticSpa)
# onto the managed Alpha host. Two fleets share one machine and are separated by
# the middle DNS label of each Silo's public URL:
#
# dev → https://<slug>.educraft-dev.paradigm-edu.net (every push + PR)
# prod → https://<slug>.educraft.paradigm-edu.net (main + tags)
#
# Example prod tenant:
# https://para-26071100.educraft.paradigm-edu.net/auth/feishu/para-26071100
#
# Required Gitea secret:
# DEPLOY_SSH_KEY — private key for root@39.107.254.4
#
# Optional secrets / vars (defaults match NEW_SILO_RUNBOOK):
# DEPLOY_HOST, DEPLOY_USER, DEPLOY_SSH_PORT, DEPLOY_BASE
# vars.ALLOW_EMPTY_FLEET=1 — green when the fleet has no silos yet
on:
push:
paths:
- "hub/**"
- ".gitea/workflows/deploy-admin.yml"
pull_request:
paths:
- "hub/**"
- ".gitea/workflows/deploy-admin.yml"
workflow_dispatch:
inputs:
fleet:
description: "Which fleet to deploy (dev | prod | both)"
required: true
default: both
allow_empty_fleet:
description: "Succeed if no silo matches (1/0)"
required: false
default: "0"
concurrency:
group: deploy-admin-host
cancel-in-progress: false
jobs:
deploy-dev:
name: deploy fleet dev (educraft-dev)
runs-on: ubuntu-latest
if: >
github.event_name == 'pull_request' ||
(github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')) ||
(github.event_name == 'workflow_dispatch' &&
(github.event.inputs.fleet == 'dev' || github.event.inputs.fleet == 'both'))
steps:
- uses: actions/checkout@v5
- name: Install rsync + ssh
run: sudo apt-get update && sudo apt-get install -y rsync openssh-client
- name: Deploy Hub + admin SPA to educraft-dev fleet
env:
CPH_FLEET: dev
PLATFORM_DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
PLATFORM_DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
PLATFORM_DEPLOY_PORT: ${{ secrets.DEPLOY_SSH_PORT }}
PLATFORM_DEPLOY_BASE: ${{ secrets.DEPLOY_BASE }}
PLATFORM_DEPLOY_RELEASE: ${{ github.sha }}
ALLOW_EMPTY_FLEET: ${{ github.event.inputs.allow_empty_fleet || vars.ALLOW_EMPTY_FLEET || '0' }}
DEPLOY_SSH_KEY_BODY: ${{ secrets.DEPLOY_SSH_KEY }}
run: |
set -euo pipefail
if [ -z "${DEPLOY_SSH_KEY_BODY:-}" ]; then
echo "missing secret DEPLOY_SSH_KEY" >&2
exit 1
fi
key="$(mktemp)"
trap 'rm -f "$key"' EXIT
printf '%s\n' "$DEPLOY_SSH_KEY_BODY" >"$key"
chmod 600 "$key"
export PLATFORM_DEPLOY_SSH_KEY="$key"
# Apply managed-host defaults when secrets are unset.
export PLATFORM_DEPLOY_HOST="${PLATFORM_DEPLOY_HOST:-39.107.254.4}"
export PLATFORM_DEPLOY_USER="${PLATFORM_DEPLOY_USER:-root}"
export PLATFORM_DEPLOY_PORT="${PLATFORM_DEPLOY_PORT:-22}"
export PLATFORM_DEPLOY_BASE="${PLATFORM_DEPLOY_BASE:-/srv/curriculum-project-hub}"
bash hub/deploy/deploy_fleet_release.sh
deploy-prod:
name: deploy fleet prod (educraft)
runs-on: ubuntu-latest
if: >
(github.event_name == 'push' && github.ref == 'refs/heads/main') ||
(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')) ||
(github.event_name == 'workflow_dispatch' &&
(github.event.inputs.fleet == 'prod' || github.event.inputs.fleet == 'both'))
steps:
- uses: actions/checkout@v5
- name: Install rsync + ssh
run: sudo apt-get update && sudo apt-get install -y rsync openssh-client
- name: Deploy Hub + admin SPA to educraft fleet
env:
CPH_FLEET: prod
PLATFORM_DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
PLATFORM_DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
PLATFORM_DEPLOY_PORT: ${{ secrets.DEPLOY_SSH_PORT }}
PLATFORM_DEPLOY_BASE: ${{ secrets.DEPLOY_BASE }}
PLATFORM_DEPLOY_RELEASE: ${{ github.sha }}
ALLOW_EMPTY_FLEET: ${{ github.event.inputs.allow_empty_fleet || vars.ALLOW_EMPTY_FLEET || '0' }}
DEPLOY_SSH_KEY_BODY: ${{ secrets.DEPLOY_SSH_KEY }}
run: |
set -euo pipefail
if [ -z "${DEPLOY_SSH_KEY_BODY:-}" ]; then
echo "missing secret DEPLOY_SSH_KEY" >&2
exit 1
fi
key="$(mktemp)"
trap 'rm -f "$key"' EXIT
printf '%s\n' "$DEPLOY_SSH_KEY_BODY" >"$key"
chmod 600 "$key"
export PLATFORM_DEPLOY_SSH_KEY="$key"
export PLATFORM_DEPLOY_HOST="${PLATFORM_DEPLOY_HOST:-39.107.254.4}"
export PLATFORM_DEPLOY_USER="${PLATFORM_DEPLOY_USER:-root}"
export PLATFORM_DEPLOY_PORT="${PLATFORM_DEPLOY_PORT:-22}"
export PLATFORM_DEPLOY_BASE="${PLATFORM_DEPLOY_BASE:-/srv/curriculum-project-hub}"
bash hub/deploy/deploy_fleet_release.sh
+26
View File
@@ -11,6 +11,32 @@ See [NEW_SILO_RUNBOOK.md](NEW_SILO_RUNBOOK.md). The remainder of this document
describes the individual installer and maintenance primitives used by the describes the individual installer and maintenance primitives used by the
generated bundle. generated bundle.
## CI: roll Hub + admin SPA by fleet (`educraft` / `educraft-dev`)
`.gitea/workflows/deploy-admin.yml` deploys Hub releases (including the org-admin
SPA) to the managed Alpha host via `deploy_fleet_release.sh`. Fleets share the
host and are selected by the middle DNS label of `HUB_PUBLIC_BASE_URL`:
| Fleet | Domain | CI trigger |
|-------|--------|------------|
| **dev** | `https://<slug>.educraft-dev.paradigm-edu.net` | every push + PR (paths under `hub/`) |
| **prod** | `https://<slug>.educraft.paradigm-edu.net` | push to `main` + tags |
Example prod tenant:
`https://para-26071100.educraft.paradigm-edu.net/auth/feishu/para-26071100`.
Required Gitea secret: `DEPLOY_SSH_KEY` (private key for `root@39.107.254.4`).
Optional: `DEPLOY_HOST` / `DEPLOY_USER` / `DEPLOY_SSH_PORT` / `DEPLOY_BASE`.
Set repository var `ALLOW_EMPTY_FLEET=1` until the first silo exists for a fleet.
Local dry-run against the host:
```sh
CPH_FLEET=dev \
PLATFORM_DEPLOY_SSH_KEY=$HOME/.ssh/id_ed25519 \
bash hub/deploy/deploy_fleet_release.sh
```
The supervised alpha runs one Organization per named Silo. The supported host The supervised alpha runs one Organization per named Silo. The supported host
has systemd, PostgreSQL, Node.js 24+, `pg_isready`, `runuser`, `setpriv`, has systemd, PostgreSQL, Node.js 24+, `pg_isready`, `runuser`, `setpriv`,
bubblewrap, `socat`, `pg_dump`, `tar`, `sha256sum`, and a compatible `cph`. bubblewrap, `socat`, `pg_dump`, `tar`, `sha256sum`, and a compatible `cph`.
+213
View File
@@ -0,0 +1,213 @@
#!/usr/bin/env bash
# Publish one immutable Hub release (tsc + admin-web SPA) to the managed host,
# then repoint every Silo in the chosen fleet to that release and restart it.
#
# Fleet is selected by the middle DNS label of each Silo's HUB_PUBLIC_BASE_URL:
#
# prod → https://<slug>.educraft.paradigm-edu.net
# dev → https://<slug>.educraft-dev.paradigm-edu.net
#
# Example tenant (prod):
# https://para-26071100.educraft.paradigm-edu.net/auth/feishu/para-26071100
#
# Configure (CI secrets / env):
# CPH_FLEET required: dev | prod
# PLATFORM_DEPLOY_HOST default 39.107.254.4
# PLATFORM_DEPLOY_USER default root
# PLATFORM_DEPLOY_SSH_KEY required (path to private key)
# PLATFORM_DEPLOY_PORT default 22
# PLATFORM_DEPLOY_BASE default /srv/curriculum-project-hub
# PLATFORM_DEPLOY_RELEASE default git HEAD (must be safe for paths)
# ALLOW_EMPTY_FLEET optional: if 1, succeed when no silo matches
#
# This is a code-roll for existing silos. It does not create databases, domains,
# or Feishu apps — use new_silo.sh / apply_new_silo.sh for that.
set -euo pipefail
FLEET="${CPH_FLEET:?CPH_FLEET required (dev|prod)}"
case "$FLEET" in
dev) FLEET_DOMAIN_SUFFIX="educraft-dev.paradigm-edu.net" ;;
prod) FLEET_DOMAIN_SUFFIX="educraft.paradigm-edu.net" ;;
*)
echo "CPH_FLEET must be dev or prod, got: $FLEET" >&2
exit 1
;;
esac
HOST="${PLATFORM_DEPLOY_HOST:-39.107.254.4}"
DEPLOY_USER="${PLATFORM_DEPLOY_USER:-root}"
PORT="${PLATFORM_DEPLOY_PORT:-22}"
SSH_KEY="${PLATFORM_DEPLOY_SSH_KEY:?PLATFORM_DEPLOY_SSH_KEY required}"
BASE="${PLATFORM_DEPLOY_BASE:-/srv/curriculum-project-hub}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
RELEASE_ID="${PLATFORM_DEPLOY_RELEASE:-$(git -C "$REPO_ROOT" rev-parse --verify HEAD)}"
[[ "$RELEASE_ID" =~ ^[A-Za-z0-9._-]+$ ]] || {
echo "invalid PLATFORM_DEPLOY_RELEASE: $RELEASE_ID" >&2
exit 1
}
ALLOW_EMPTY_FLEET="${ALLOW_EMPTY_FLEET:-0}"
HUB_DIR="$BASE/releases/$RELEASE_ID/hub"
RELEASE_DIR="$BASE/releases/$RELEASE_ID"
SSH_OPTS=(-i "$SSH_KEY" -p "$PORT" -o BatchMode=yes -o StrictHostKeyChecking=accept-new)
echo "[fleet] fleet=$FLEET domain=*.$FLEET_DOMAIN_SUFFIX host=$DEPLOY_USER@$HOST release=$RELEASE_ID"
# --- 1. Publish immutable release (shared; flock so parallel fleet jobs do not race) ---
release_ready=false
if ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" "test -f '$RELEASE_DIR/.complete'"; then
release_ready=true
echo "[fleet] release already complete: $RELEASE_DIR"
fi
if [ "$release_ready" = false ]; then
echo "[fleet] rsync hub → $HUB_DIR"
ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" \
"flock /var/lock/cph-hub-release-publish mkdir -p '$HUB_DIR'"
rsync -az --delete \
--exclude node_modules --exclude dist --exclude .env \
--exclude admin-web/node_modules --exclude admin-web/build --exclude admin-web/.svelte-kit \
-e "ssh ${SSH_OPTS[*]}" \
"$REPO_ROOT/hub/" "$DEPLOY_USER@$HOST:$HUB_DIR/"
echo "[fleet] npm ci + build (tsc + admin-web SPA)"
ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" bash -s <<REMOTE
set -euo pipefail
flock /var/lock/cph-hub-release-publish bash -c '
set -euo pipefail
if [ -f "$RELEASE_DIR/.complete" ]; then
echo "[fleet] release completed by another job"
exit 0
fi
if [ -e "$RELEASE_DIR" ] && [ ! -f "$RELEASE_DIR/.complete" ] && [ ! -d "$HUB_DIR" ]; then
echo "incomplete release tree: $RELEASE_DIR" >&2
exit 1
fi
cd "$HUB_DIR"
npm ci
npm ci --prefix admin-web
npm run audit:production
npm run build
test -f admin-web/build/index.html
touch "$RELEASE_DIR/.complete"
'
REMOTE
fi
# --- 2. Discover silos in this fleet and roll them onto the release ---
ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" \
env BASE="$BASE" HUB_DIR="$HUB_DIR" FLEET="$FLEET" \
FLEET_DOMAIN_SUFFIX="$FLEET_DOMAIN_SUFFIX" ALLOW_EMPTY_FLEET="$ALLOW_EMPTY_FLEET" \
bash -s <<'REMOTE'
set -euo pipefail
fleet_match() {
local public_url="$1"
local host="${public_url#https://}"
host="${host#http://}"
host="${host%%/*}"
host="${host%%:*}"
case "$FLEET" in
prod)
[[ "$host" == *."$FLEET_DOMAIN_SUFFIX" || "$host" == "$FLEET_DOMAIN_SUFFIX" ]] || return 1
# Avoid matching educraft-dev when suffix is educraft.…
[[ "$host" != *.educraft-dev.paradigm-edu.net && "$host" != educraft-dev.paradigm-edu.net ]] || return 1
return 0
;;
dev)
[[ "$host" == *."$FLEET_DOMAIN_SUFFIX" || "$host" == "$FLEET_DOMAIN_SUFFIX" ]]
return
;;
*)
return 1
;;
esac
}
env_value() {
local file="$1"
local key="$2"
# Do not source secrets — only read the KEY=value line.
local line
line="$(grep -E "^${key}=" "$file" | tail -n1 || true)"
printf '%s' "${line#"${key}="}"
}
matched=0
shopt -s nullglob
for env_file in "$BASE"/.secrets/*/platform.env; do
instance_id="$(basename "$(dirname "$env_file")")"
public_url="$(env_value "$env_file" HUB_PUBLIC_BASE_URL)"
if [ -z "$public_url" ]; then
echo "[fleet] skip $instance_id: HUB_PUBLIC_BASE_URL empty"
continue
fi
if ! fleet_match "$public_url"; then
echo "[fleet] skip $instance_id: $public_url (not $FLEET)"
continue
fi
port="$(env_value "$env_file" PORT)"
workspace="$(env_value "$env_file" HUB_PROJECT_WORKSPACE_ROOT)"
unit="cph-hub-${instance_id}.service"
if [ ! -f "/etc/systemd/system/$unit" ]; then
echo "[fleet] skip $instance_id: unit missing ($unit)" >&2
continue
fi
# Prefer unit file ceilings (MemoryMax=16G) over systemctl's byte form.
memory_max="$(sed -n 's/^MemoryMax=//p' "/etc/systemd/system/$unit" | tail -n1)"
cpu_quota="$(sed -n 's/^CPUQuota=//p' "/etc/systemd/system/$unit" | tail -n1)"
tasks_max="$(sed -n 's/^TasksMax=//p' "/etc/systemd/system/$unit" | tail -n1)"
if [ -z "$port" ] || [ -z "$workspace" ] || [ -z "$memory_max" ] || [ -z "$cpu_quota" ] || [ -z "$tasks_max" ]; then
echo "[fleet] error: $instance_id missing PORT/workspace/ceilings (port=$port workspace=$workspace memory=$memory_max cpu=$cpu_quota tasks=$tasks_max)" >&2
exit 1
fi
echo "[fleet] roll $instance_id → $HUB_DIR ($public_url port=$port)"
BASE="$BASE" \
HUB_DIR="$HUB_DIR" \
INSTANCE_ID="$instance_id" \
WORKSPACE_ROOT="$workspace" \
PORT="$port" \
MEMORY_MAX="$memory_max" \
CPU_QUOTA="$cpu_quota" \
TASKS_MAX="$tasks_max" \
bash "$HUB_DIR/deploy/install_service.sh"
systemctl restart "$unit"
systemctl is-active --quiet "$unit"
health="http://127.0.0.1:${port}/api/healthz"
ok=false
for _ in $(seq 1 45); do
if curl --fail --silent "$health" >/dev/null 2>&1; then
ok=true
break
fi
sleep 1
done
if [ "$ok" != true ]; then
curl --fail --silent --show-error "$health" >/dev/null
fi
echo "[fleet] healthy $instance_id ($health)"
matched=$((matched + 1))
done
if [ "$matched" -eq 0 ]; then
msg="[fleet] no silos matched *.$FLEET_DOMAIN_SUFFIX under $BASE/.secrets"
if [ "$ALLOW_EMPTY_FLEET" = "1" ]; then
echo "$msg (ALLOW_EMPTY_FLEET=1)"
exit 0
fi
echo "$msg" >&2
exit 1
fi
echo "[fleet] rolled $matched silo(s) on $FLEET"
REMOTE
echo "[fleet] done fleet=$FLEET release=$RELEASE_ID hub=$HUB_DIR"