Pulse/.github/workflows/deploy-demo-server.yml
rcourtman 76ced45c3a
Some checks are pending
Build and Test / Secret Scan (push) Waiting to run
Build and Test / Frontend & Backend (push) Waiting to run
Canonical Governance / governance (push) Waiting to run
Helm CI / Lint and Render Chart (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 1/4) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 2/4) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 3/4) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 4/4) (push) Waiting to run
Core E2E Tests / E2E verdict (push) Blocked by required conditions
Harden demo SSH setup for private deploy hosts
2026-07-09 16:51:38 +01:00

355 lines
14 KiB
YAML

name: Deploy Demo Server
on:
workflow_dispatch:
inputs:
target:
description: 'Demo target to deploy'
required: true
default: stable
type: choice
options:
- stable
# 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"
;;
*)
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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: go.mod
cache: true
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
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@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
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:
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
tags: tag:infra
- 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/known_hosts
keyscan_output="$(mktemp)"
keyscan_error="$(mktemp)"
MAX_SSH_SETUP_ATTEMPTS=18
for attempt in $(seq 1 "$MAX_SSH_SETUP_ATTEMPTS"); do
if getent hosts "$DEMO_SERVER_HOST" >/dev/null 2>&1; then
if ssh-keyscan -T 10 -H "$DEMO_SERVER_HOST" > "$keyscan_output" 2>"$keyscan_error" && [ -s "$keyscan_output" ]; then
cat "$keyscan_output" >> ~/.ssh/known_hosts
chmod 600 ~/.ssh/known_hosts
echo "Demo SSH host key captured."
exit 0
fi
echo "ssh-keyscan did not return demo host keys on attempt ${attempt}/${MAX_SSH_SETUP_ATTEMPTS}."
else
echo "Demo SSH host is not resolvable yet on attempt ${attempt}/${MAX_SSH_SETUP_ATTEMPTS}."
fi
if [ "$attempt" -lt "$MAX_SSH_SETUP_ATTEMPTS" ]; then
sleep 10
fi
done
echo "::error::Timed out waiting for the demo SSH host to resolve and return host keys after Tailscale setup."
tailscale status --peers=false || true
if [ -s "$keyscan_error" ]; then
sed 's/^/ssh-keyscan: /' "$keyscan_error" || true
fi
exit 1
- 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
SERVICE_NAME="pulse"
fi
if [ "$TARGET" != "stable" ]; then
echo "::error::Unsupported demo target: ${TARGET}"
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