Pulse/.github/workflows/update-demo-server.yml

444 lines
17 KiB
YAML

name: Update Demo Server
on:
release:
types: [published]
workflow_dispatch:
inputs:
tag:
description: 'Release tag to deploy (e.g., v6.0.0-rc.1)'
required: true
type: string
target:
description: 'Demo target to deploy'
required: false
default: auto
type: choice
options:
- auto
- stable
- preview-v6
jobs:
resolve:
runs-on: ubuntu-latest
outputs:
tag: ${{ steps.target.outputs.tag }}
target: ${{ steps.target.outputs.target }}
environment_name: ${{ steps.target.outputs.environment_name }}
skip: ${{ steps.latest.outputs.skip || 'false' }}
steps:
- name: Resolve target tag and demo environment
id: target
run: |
set -euo pipefail
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
TAG="${{ inputs.tag }}"
REQUESTED_TARGET="${{ inputs.target }}"
else
TAG="${{ github.event.release.tag_name }}"
REQUESTED_TARGET="auto"
fi
VERSION="${TAG#v}"
IS_PRERELEASE=false
if [[ "$VERSION" =~ -(rc|alpha|beta)\.[0-9]+$ ]]; then
IS_PRERELEASE=true
fi
TARGET="${REQUESTED_TARGET:-auto}"
if [ -z "$TARGET" ] || [ "$TARGET" = "auto" ]; then
if [ "$IS_PRERELEASE" = "true" ]; then
TARGET="preview-v6"
else
TARGET="stable"
fi
fi
case "$TARGET" in
stable)
if [ "$IS_PRERELEASE" = "true" ]; then
echo "::error::Stable demo target only accepts stable tags. Refusing prerelease tag ${TAG}."
exit 1
fi
ENVIRONMENT_NAME="demo-stable"
;;
preview-v6)
if [ "$IS_PRERELEASE" != "true" ]; then
echo "::error::Preview demo target only accepts prerelease tags. Refusing stable tag ${TAG}."
exit 1
fi
ENVIRONMENT_NAME="demo-preview-v6"
;;
*)
echo "::error::Unsupported demo target: ${TARGET}"
exit 1
;;
esac
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "target=$TARGET" >> "$GITHUB_OUTPUT"
echo "environment_name=$ENVIRONMENT_NAME" >> "$GITHUB_OUTPUT"
echo "Resolved demo deployment: tag=${TAG}, target=${TARGET}, environment=${ENVIRONMENT_NAME}"
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-tags: true
- name: Validate governed release line for selected tag
run: |
set -euo pipefail
TAG="${{ steps.target.outputs.tag }}"
VERSION="${TAG#v}"
if [ "$(git rev-parse --is-shallow-repository)" = "true" ]; then
git fetch --prune --unshallow origin
fi
REQUIRED_BRANCH="$(python3 scripts/release_control/control_plane.py --branch-for-version "${VERSION}")"
git fetch --prune origin "${REQUIRED_BRANCH}" --tags
if ! git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then
echo "::error::Tag ${TAG} does not exist in repository tags."
exit 1
fi
TAG_COMMIT="$(git rev-list -n1 "refs/tags/${TAG}")"
if ! git merge-base --is-ancestor "$TAG_COMMIT" "origin/${REQUIRED_BRANCH}"; then
echo "::error::Tag ${TAG} is not reachable from origin/${REQUIRED_BRANCH}. Refusing demo deployment."
exit 1
fi
echo "[OK] ${TAG} validated for governed demo deployment on ${REQUIRED_BRANCH}"
- name: Skip if not latest published release for target
id: latest
if: github.event_name == 'release'
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
TARGET="${{ steps.target.outputs.target }}"
TAG="${{ steps.target.outputs.tag }}"
if [ "$TARGET" = "stable" ]; then
LATEST=$(gh api "repos/${{ github.repository }}/releases/latest" --jq '.tag_name')
else
LATEST=$(gh api "repos/${{ github.repository }}/releases?per_page=100" --jq '[.[] | select(.draft == false and .prerelease == true)][0].tag_name')
fi
echo "Target tag: $TAG"
echo "Latest published tag for $TARGET: $LATEST"
if [ -z "$LATEST" ] || [ "$LATEST" = "null" ]; then
echo "::error::Could not determine the latest published release for demo target $TARGET."
exit 1
fi
if [ "$TAG" != "$LATEST" ]; then
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "Release is not the latest published tag for ${TARGET}; skipping demo update."
else
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
update-demo:
needs: resolve
if: needs.resolve.outputs.skip != 'true'
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_PUBLIC_HEALTH_URL: ${{ vars.DEMO_PUBLIC_HEALTH_URL }}
DEMO_SERVICE_NAME: ${{ vars.DEMO_SERVICE_NAME }}
DEMO_AUTH_USER: ${{ vars.DEMO_AUTH_USER }}
DEMO_AUTH_PASS: ${{ vars.DEMO_AUTH_PASS }}
steps:
- name: Check release type
run: |
echo "Tag: ${{ needs.resolve.outputs.tag }}"
echo "Target: ${{ needs.resolve.outputs.target }}"
echo "Environment: ${{ needs.resolve.outputs.environment_name }}"
- 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; }
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-tags: true
- name: Wait for release assets
run: |
set -euo pipefail
TAG="${{ needs.resolve.outputs.tag }}"
echo "Waiting for release assets to be available..."
MAX_ATTEMPTS=30
ATTEMPT=0
while [ $ATTEMPT -lt $MAX_ATTEMPTS ]; do
echo "Checking for assets (attempt $((ATTEMPT + 1))/$MAX_ATTEMPTS)..."
CHECKSUMS_STATUS=$(curl -sL -o /dev/null -w "%{http_code}" \
"https://github.com/rcourtman/Pulse/releases/download/${TAG}/checksums.txt")
TARBALL_STATUS=$(curl -sL -o /dev/null -w "%{http_code}" \
"https://github.com/rcourtman/Pulse/releases/download/${TAG}/pulse-${TAG}-linux-amd64.tar.gz")
echo "checksums.txt: $CHECKSUMS_STATUS, tarball: $TARBALL_STATUS"
if [ "$CHECKSUMS_STATUS" = "200" ] && [ "$TARBALL_STATUS" = "200" ]; then
echo "Release assets are available."
exit 0
fi
ATTEMPT=$((ATTEMPT + 1))
if [ $ATTEMPT -lt $MAX_ATTEMPTS ]; then
echo "Assets not ready yet, waiting 10 seconds..."
sleep 10
fi
done
echo "::error::Timeout waiting for release assets"
exit 1
- name: Materialize tagged installer
run: |
set -euo pipefail
TAG="${{ needs.resolve.outputs.tag }}"
git show "refs/tags/${TAG}:install.sh" > /tmp/pulse-install.sh
chmod +x /tmp/pulse-install.sh
- 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 demo service identity
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 updates must not target the stable pulse service."
exit 1
fi
echo "service_name=$SERVICE_NAME" >> "$GITHUB_OUTPUT"
- name: Check current demo version
id: current
env:
DEMO_SERVER_HOST: ${{ secrets.DEMO_SERVER_HOST }}
DEMO_SERVER_USER: ${{ secrets.DEMO_SERVER_USER }}
run: |
set -euo pipefail
TARGET="${{ needs.resolve.outputs.tag }}"
TARGET_STRIPPED="${TARGET#v}"
CURRENT=$(ssh -i ~/.ssh/id_ed25519 "$DEMO_SERVER_USER@$DEMO_SERVER_HOST" \
"curl -fsS ${DEMO_LOCAL_BASE_URL}/api/version | jq -r .version")
echo "Current demo version: ${CURRENT}"
if [ "$CURRENT" = "$TARGET" ] || [ "$CURRENT" = "$TARGET_STRIPPED" ] || [ "v${CURRENT}" = "$TARGET" ]; then
echo "skip_current=true" >> "$GITHUB_OUTPUT"
echo "Demo target already on the requested version; skipping update."
else
echo "skip_current=false" >> "$GITHUB_OUTPUT"
fi
- name: Upload tagged installer
if: steps.current.outputs.skip_current != 'true'
env:
DEMO_SERVER_HOST: ${{ secrets.DEMO_SERVER_HOST }}
DEMO_SERVER_USER: ${{ secrets.DEMO_SERVER_USER }}
run: |
set -euo pipefail
scp -i ~/.ssh/id_ed25519 /tmp/pulse-install.sh "$DEMO_SERVER_USER@$DEMO_SERVER_HOST:/tmp/pulse-install.sh"
- name: Update demo server
if: steps.current.outputs.skip_current != 'true'
env:
DEMO_SERVER_HOST: ${{ secrets.DEMO_SERVER_HOST }}
DEMO_SERVER_USER: ${{ secrets.DEMO_SERVER_USER }}
run: |
set -euo pipefail
TAG="${{ needs.resolve.outputs.tag }}"
SERVICE_NAME="${{ steps.config.outputs.service_name }}"
REMOTE_SCRIPT=$(cat <<'EOF'
set -euo pipefail
TAG="$1"
SERVICE_NAME="$2"
INSTALLER_ENV=()
if [ -n "$SERVICE_NAME" ]; then
INSTALLER_ENV+=("PULSE_SERVICE_NAME=$SERVICE_NAME")
fi
sudo env "${INSTALLER_ENV[@]}" bash /tmp/pulse-install.sh --version "$TAG"
rm -f /tmp/pulse-install.sh
EOF
)
ssh -i ~/.ssh/id_ed25519 "$DEMO_SERVER_USER@$DEMO_SERVER_HOST" "bash -s -- $(printf '%q ' "$TAG" "$SERVICE_NAME")" <<<"$REMOTE_SCRIPT"
- name: Verify update
if: steps.current.outputs.skip_current != 'true'
env:
DEMO_SERVER_HOST: ${{ secrets.DEMO_SERVER_HOST }}
DEMO_SERVER_USER: ${{ secrets.DEMO_SERVER_USER }}
run: |
set -euo pipefail
sleep 5
VERSION=$(ssh -i ~/.ssh/id_ed25519 "$DEMO_SERVER_USER@$DEMO_SERVER_HOST" \
"curl -fsS ${DEMO_LOCAL_BASE_URL}/api/version | jq -r .version")
echo "Demo server is now running version: $VERSION"
TAG="${{ needs.resolve.outputs.tag }}"
TAG_STRIPPED="${TAG#v}"
if [ "$VERSION" != "$TAG" ] && [ "$VERSION" != "$TAG_STRIPPED" ]; then
echo "::error::Version mismatch! Expected $TAG but got $VERSION"
exit 1
fi
AUTH_USER="${DEMO_AUTH_USER:-demo}"
AUTH_PASS="${DEMO_AUTH_PASS:-demo}"
NODES=$(ssh -i ~/.ssh/id_ed25519 "$DEMO_SERVER_USER@$DEMO_SERVER_HOST" \
'AUTH_USER='"$(printf '%q' "$AUTH_USER")"' AUTH_PASS='"$(printf '%q' "$AUTH_PASS")"' DEMO_LOCAL_BASE_URL='"$(printf '%q' "$DEMO_LOCAL_BASE_URL")"' bash -s' <<'EOF'
set -euo pipefail
COOKIE_FILE="$HOME/.demo-cookies.txt"
curl -fsS -c "$COOKIE_FILE" "${DEMO_LOCAL_BASE_URL}/api/login" -X POST \
-H "Content-Type: application/json" \
-d "{\"username\":\"${AUTH_USER}\",\"password\":\"${AUTH_PASS}\"}" > /dev/null
curl -fsS -b "$COOKIE_FILE" "${DEMO_LOCAL_BASE_URL}/api/state" | jq -r '.nodes | length'
rm -f "$COOKIE_FILE"
EOF
)
echo "Mock nodes detected: $NODES"
if [ "$NODES" -ge 1 ]; then
echo "Demo server successfully updated and verified."
else
echo "::error::Demo server updated but mock mode may not be active"
exit 1
fi
- name: Verify frontend parity
env:
DEMO_SERVER_HOST: ${{ secrets.DEMO_SERVER_HOST }}
DEMO_SERVER_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
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))'
}
if ! REMOTE_ASSET="$(
ssh -i ~/.ssh/id_ed25519 "$DEMO_SERVER_USER@$DEMO_SERVER_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
[ "$PUBLIC_ASSET" = "$REMOTE_ASSET" ] || {
echo "::error::Public demo is serving $PUBLIC_ASSET but the target service is serving $REMOTE_ASSET."
exit 1
}
- name: Verify public health
run: |
set -euo pipefail
curl -fsS "$DEMO_PUBLIC_HEALTH_URL"
- 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 /tmp/pulse-install.sh