mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-07 00:37:36 +00:00
335 lines
13 KiB
YAML
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
|