Pulse/.github/workflows/deploy-demo-server.yml

335 lines
13 KiB
YAML

name: Deploy Demo Server
on:
workflow_dispatch:
inputs:
target:
description: 'Demo target to deploy'
required: true
default: stable
type: choice
options:
- stable
- preview-v6
# schedule:
# - cron: '0 0 * * *' # Nightly at midnight
jobs:
resolve:
runs-on: ubuntu-latest
outputs:
target: ${{ steps.target.outputs.target }}
environment_name: ${{ steps.target.outputs.environment_name }}
steps:
- name: Resolve demo target environment
id: target
run: |
set -euo pipefail
TARGET="${{ inputs.target }}"
case "$TARGET" in
stable)
ENVIRONMENT_NAME="demo-stable"
;;
preview-v6)
ENVIRONMENT_NAME="demo-preview-v6"
;;
*)
echo "::error::Unsupported demo target: ${TARGET}"
exit 1
;;
esac
echo "target=$TARGET" >> "$GITHUB_OUTPUT"
echo "environment_name=$ENVIRONMENT_NAME" >> "$GITHUB_OUTPUT"
deploy:
needs: resolve
runs-on: ubuntu-latest
environment: ${{ needs.resolve.outputs.environment_name }}
env:
DEMO_EXPECTED_HOSTNAME: ${{ vars.DEMO_EXPECTED_HOSTNAME }}
DEMO_LOCAL_BASE_URL: ${{ vars.DEMO_LOCAL_BASE_URL }}
DEMO_SERVICE_NAME: ${{ vars.DEMO_SERVICE_NAME }}
DEMO_INSTALL_DIR: ${{ vars.DEMO_INSTALL_DIR }}
DEMO_PUBLIC_HEALTH_URL: ${{ vars.DEMO_PUBLIC_HEALTH_URL }}
DEMO_TEST_PORT: ${{ vars.DEMO_TEST_PORT }}
DEMO_AUTH_USER: ${{ vars.DEMO_AUTH_USER }}
DEMO_AUTH_PASS: ${{ vars.DEMO_AUTH_PASS }}
steps:
- name: Validate demo environment configuration
env:
DEMO_SERVER_HOST: ${{ secrets.DEMO_SERVER_HOST }}
DEMO_SERVER_USER: ${{ secrets.DEMO_SERVER_USER }}
DEMO_SERVER_SSH_KEY: ${{ secrets.DEMO_SERVER_SSH_KEY }}
run: |
set -euo pipefail
[ -n "$DEMO_SERVER_HOST" ] || { echo "::error::DEMO_SERVER_HOST is required in the selected demo environment."; exit 1; }
[ -n "$DEMO_SERVER_USER" ] || { echo "::error::DEMO_SERVER_USER is required in the selected demo environment."; exit 1; }
[ -n "$DEMO_SERVER_SSH_KEY" ] || { echo "::error::DEMO_SERVER_SSH_KEY is required in the selected demo environment."; exit 1; }
[ -n "$DEMO_EXPECTED_HOSTNAME" ] || { echo "::error::DEMO_EXPECTED_HOSTNAME is required in the selected demo environment."; exit 1; }
[ -n "$DEMO_LOCAL_BASE_URL" ] || { echo "::error::DEMO_LOCAL_BASE_URL is required in the selected demo environment."; exit 1; }
[ -n "$DEMO_PUBLIC_HEALTH_URL" ] || { echo "::error::DEMO_PUBLIC_HEALTH_URL is required in the selected demo environment."; exit 1; }
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.25.7'
cache: true
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: 'frontend-modern/package-lock.json'
- name: Install frontend dependencies
run: npm --prefix frontend-modern ci
- name: Restore frontend build cache
uses: actions/cache@v4
with:
path: frontend-modern/dist
key: frontend-build-${{ hashFiles('frontend-modern/package-lock.json', 'frontend-modern/src/**/*', 'frontend-modern/index.html', 'frontend-modern/postcss.config.cjs', 'frontend-modern/tailwind.config.cjs') }}
- name: Build frontend
run: |
if [ -d "frontend-modern/dist" ] && [ -f "frontend-modern/dist/index.html" ]; then
echo "Using cached frontend build"
else
npm --prefix frontend-modern run build
fi
rm -rf internal/api/frontend-modern
mkdir -p internal/api/frontend-modern
cp -r frontend-modern/dist internal/api/frontend-modern/
- name: Capture expected frontend entry asset
id: frontend
run: |
set -euo pipefail
ENTRY_ASSET="$(grep -o '/assets/index-[^\" ]*\.js' frontend-modern/dist/index.html | head -n 1)"
[ -n "$ENTRY_ASSET" ] || { echo "::error::Unable to resolve the built frontend entry asset."; exit 1; }
echo "entry_asset=$ENTRY_ASSET" >> "$GITHUB_OUTPUT"
- name: Build static binary
env:
PULSE_LICENSE_PUBLIC_KEY: ${{ secrets.PULSE_LICENSE_PUBLIC_KEY }}
run: |
set -euo pipefail
VERSION="v$(cat VERSION | tr -d '\n')"
BUILD_TIME=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
GIT_COMMIT=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")
SERVER_LDFLAGS="$(if [ -n "${PULSE_LICENSE_PUBLIC_KEY:-}" ]; then ./scripts/release_ldflags.sh server --version "${VERSION}" --build-time "${BUILD_TIME}" --git-commit "${GIT_COMMIT}" --license-public-key "${PULSE_LICENSE_PUBLIC_KEY}"; else ./scripts/release_ldflags.sh server --version "${VERSION}" --build-time "${BUILD_TIME}" --git-commit "${GIT_COMMIT}"; fi)"
CGO_ENABLED=0 go build \
-ldflags="${SERVER_LDFLAGS}" \
-trimpath \
-o pulse ./cmd/pulse/
- name: Tailscale
uses: tailscale/github-action@v2
with:
authkey: ${{ secrets.TS_AUTHKEY }}
- name: Setup SSH
env:
DEMO_SERVER_HOST: ${{ secrets.DEMO_SERVER_HOST }}
DEMO_SERVER_SSH_KEY: ${{ secrets.DEMO_SERVER_SSH_KEY }}
run: |
set -euo pipefail
mkdir -p ~/.ssh
chmod 700 ~/.ssh
echo "$DEMO_SERVER_SSH_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -H "$DEMO_SERVER_HOST" >> ~/.ssh/known_hosts
chmod 600 ~/.ssh/known_hosts
- name: Verify target host identity
env:
DEMO_SERVER_HOST: ${{ secrets.DEMO_SERVER_HOST }}
DEMO_SERVER_USER: ${{ secrets.DEMO_SERVER_USER }}
run: |
set -euo pipefail
REMOTE_HOSTNAME="$(
ssh -i ~/.ssh/id_ed25519 \
-o IdentitiesOnly=yes \
-o StrictHostKeyChecking=yes \
-o UserKnownHostsFile=~/.ssh/known_hosts \
"$DEMO_SERVER_USER@$DEMO_SERVER_HOST" \
"hostname"
)"
[ "$REMOTE_HOSTNAME" = "$DEMO_EXPECTED_HOSTNAME" ] || {
echo "::error::Demo environment points at host $REMOTE_HOSTNAME but expected $DEMO_EXPECTED_HOSTNAME."
exit 1
}
- name: Resolve deploy paths
id: config
run: |
set -euo pipefail
TARGET="${{ needs.resolve.outputs.target }}"
SERVICE_NAME="${DEMO_SERVICE_NAME:-}"
if [ -z "$SERVICE_NAME" ]; then
case "$TARGET" in
stable)
SERVICE_NAME="pulse"
;;
preview-v6)
SERVICE_NAME="pulse-v6-preview"
;;
*)
echo "::error::Unsupported demo target: ${TARGET}"
exit 1
;;
esac
fi
if [ "$TARGET" = "preview-v6" ] && [ "$SERVICE_NAME" = "pulse" ]; then
echo "::error::Preview demo deployments must not target the stable pulse service."
exit 1
fi
INSTALL_DIR="${DEMO_INSTALL_DIR:-}"
if [ -z "$INSTALL_DIR" ]; then
if [ "$SERVICE_NAME" = "pulse" ]; then
INSTALL_DIR="/opt/pulse"
else
INSTALL_DIR="/opt/$SERVICE_NAME"
fi
fi
TEST_PORT="${DEMO_TEST_PORT:-8082}"
AUTH_USER="${DEMO_AUTH_USER:-demo}"
AUTH_PASS="${DEMO_AUTH_PASS:-demo}"
echo "service_name=$SERVICE_NAME" >> "$GITHUB_OUTPUT"
echo "install_dir=$INSTALL_DIR" >> "$GITHUB_OUTPUT"
echo "test_port=$TEST_PORT" >> "$GITHUB_OUTPUT"
echo "auth_user=$AUTH_USER" >> "$GITHUB_OUTPUT"
echo "auth_pass=$AUTH_PASS" >> "$GITHUB_OUTPUT"
- name: Deploy to server
env:
DEPLOY_HOST: ${{ secrets.DEMO_SERVER_HOST }}
DEPLOY_USER: ${{ secrets.DEMO_SERVER_USER }}
run: |
set -euo pipefail
SSH_OPTS=(
-i ~/.ssh/id_ed25519
-o IdentitiesOnly=yes
-o StrictHostKeyChecking=yes
-o UserKnownHostsFile=~/.ssh/known_hosts
)
TARGET_SERVICE="${{ steps.config.outputs.service_name }}"
INSTALL_DIR="${{ steps.config.outputs.install_dir }}"
TEST_PORT="${{ steps.config.outputs.test_port }}"
AUTH_USER="${{ steps.config.outputs.auth_user }}"
AUTH_PASS="${{ steps.config.outputs.auth_pass }}"
ssh "${SSH_OPTS[@]}" "${DEPLOY_USER}@${DEPLOY_HOST}" "rm -f /tmp/pulse-new; rm -rf /tmp/pulse-demo-test" || true
scp "${SSH_OPTS[@]}" pulse "${DEPLOY_USER}@${DEPLOY_HOST}:/tmp/pulse-new"
ssh "${SSH_OPTS[@]}" "${DEPLOY_USER}@${DEPLOY_HOST}" \
"TARGET_SERVICE=$(printf '%q' "$TARGET_SERVICE") INSTALL_DIR=$(printf '%q' "$INSTALL_DIR") TEST_PORT=$(printf '%q' "$TEST_PORT") AUTH_USER=$(printf '%q' "$AUTH_USER") AUTH_PASS=$(printf '%q' "$AUTH_PASS") bash -s" <<'EOF'
set -euo pipefail
chmod +x /tmp/pulse-new
PULSE_DATA_DIR=/tmp/pulse-demo-test \
FRONTEND_PORT="$TEST_PORT" \
PULSE_MOCK_MODE=true \
PULSE_AUTH_USER="$AUTH_USER" \
PULSE_AUTH_PASS="$AUTH_PASS" \
nohup /tmp/pulse-new > /tmp/demo-deploy-test.log 2>&1 &
PID=$!
sleep 5
if curl -fsS "http://127.0.0.1:${TEST_PORT}/api/health"; then
echo 'Health check passed'
kill "$PID"
rm -rf /tmp/pulse-demo-test
rm -f /tmp/demo-deploy-test.log
else
echo 'Health check failed'
cat /tmp/demo-deploy-test.log || true
kill "$PID" || true
rm -rf /tmp/pulse-demo-test
exit 1
fi
sudo test -d "$INSTALL_DIR/bin"
sudo systemctl stop "$TARGET_SERVICE"
sudo mv /tmp/pulse-new "$INSTALL_DIR/bin/pulse"
sudo systemctl start "$TARGET_SERVICE"
EOF
- name: Verify production
run: |
set -euo pipefail
sleep 5
curl -fsS "$DEMO_PUBLIC_HEALTH_URL"
- name: Verify frontend parity
env:
DEPLOY_HOST: ${{ secrets.DEMO_SERVER_HOST }}
DEPLOY_USER: ${{ secrets.DEMO_SERVER_USER }}
run: |
set -euo pipefail
case "$DEMO_PUBLIC_HEALTH_URL" in
*/api/health)
PUBLIC_ROOT_URL="${DEMO_PUBLIC_HEALTH_URL%/api/health}/"
;;
*)
echo "::error::DEMO_PUBLIC_HEALTH_URL must end with /api/health for frontend verification."
exit 1
;;
esac
SSH_OPTS=(
-i ~/.ssh/id_ed25519
-o IdentitiesOnly=yes
-o StrictHostKeyChecking=yes
-o UserKnownHostsFile=~/.ssh/known_hosts
)
extract_entry_asset() {
python3 -c 'import re, sys; html = sys.stdin.read(); match = re.search(r"<script\b[^>]*\bsrc=\"(/assets/index-[^\"]*\.js)\"", html); sys.exit(1) if match is None else sys.stdout.write(match.group(1))'
}
EXPECTED_ASSET="${{ steps.frontend.outputs.entry_asset }}"
if ! REMOTE_ASSET="$(
ssh "${SSH_OPTS[@]}" "${DEPLOY_USER}@${DEPLOY_HOST}" \
"curl -fsS ${DEMO_LOCAL_BASE_URL}/" | extract_entry_asset
)"; then
echo "::error::Failed to resolve the remote frontend entry asset."
exit 1
fi
if ! PUBLIC_ASSET="$(curl -fsS "$PUBLIC_ROOT_URL" | extract_entry_asset)"; then
echo "::error::Failed to resolve the public frontend entry asset."
exit 1
fi
[ "$REMOTE_ASSET" = "$EXPECTED_ASSET" ] || {
echo "::error::Remote service is serving $REMOTE_ASSET but the build expected $EXPECTED_ASSET."
exit 1
}
[ "$PUBLIC_ASSET" = "$EXPECTED_ASSET" ] || {
echo "::error::Public demo is serving $PUBLIC_ASSET but the build expected $EXPECTED_ASSET."
exit 1
}
- name: Verify public browser smoke
run: |
set -euo pipefail
case "$DEMO_PUBLIC_HEALTH_URL" in
*/api/health)
export PULSE_PUBLIC_SITE_URL="${DEMO_PUBLIC_HEALTH_URL%/api/health}/"
;;
*)
echo "::error::DEMO_PUBLIC_HEALTH_URL must end with /api/health for browser verification."
exit 1
;;
esac
./scripts/run_demo_public_browser_smoke.sh
- name: Cleanup SSH material
if: always()
run: rm -f ~/.ssh/id_ed25519 ~/.ssh/known_hosts