mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
Tighten v5-to-v6 upgrade safety, release installability, provider MSP mode handling, AI cost accounting, metrics flushing, and frontend guardrails for the v6.0.0 GA candidate.
341 lines
13 KiB
YAML
341 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
|
|
|
|
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
|
|
env:
|
|
INPUT_TARGET: ${{ inputs.target }}
|
|
run: |
|
|
set -euo pipefail
|
|
TARGET="$INPUT_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-file: go.mod
|
|
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"<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
|