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 permissions: contents: read jobs: resolve: runs-on: ubuntu-24.04 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-24.04 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@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Set up Go uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 with: go-version: '1.25.9' cache: true - name: Set up Node.js uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # 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@0057852bfaa89a56745cba8c7296529d2fc39830 # 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}" \ -buildvcs=false \ -trimpath \ -o pulse ./cmd/pulse/ - name: Tailscale uses: tailscale/github-action@4e4c49acaa9818630ce0bd7a564372c17e33fb4d # 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"]*\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