mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
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
355 lines
14 KiB
YAML
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
|