mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-26 21:47:20 +00:00
a2fc29b70b
Retain the rejected VERSION rollback result. Replace general installer execution with pinned-signature archive selection and atomic binary/VERSION swaps, preserve unrelated distribution assets, and fail closed on missing matching native acceptance before production mutation. Change-source: pulse-maintainer
614 lines
27 KiB
YAML
614 lines
27 KiB
YAML
name: Update Demo Server
|
|
|
|
on:
|
|
workflow_call:
|
|
inputs:
|
|
tag:
|
|
description: 'Stable release tag to deploy, or latest for verification-only checks'
|
|
required: true
|
|
type: string
|
|
target:
|
|
description: 'Demo target to deploy'
|
|
required: false
|
|
default: stable
|
|
type: string
|
|
verify_only:
|
|
description: 'Verify connectivity and the current stable demo without changing the host'
|
|
required: false
|
|
default: false
|
|
type: boolean
|
|
activation_convergence_run_id:
|
|
description: 'Exact Release Convergence owner. Required for every mutating call.'
|
|
required: false
|
|
default: ''
|
|
type: string
|
|
customer_promotion_lease_sha:
|
|
description: 'Exact active customer-promotion lease. Required for every mutating call.'
|
|
required: false
|
|
default: ''
|
|
type: string
|
|
convergence_owner_asset_name:
|
|
description: 'Owner record stored in the exact convergence lease commit.'
|
|
required: false
|
|
default: ''
|
|
type: string
|
|
convergence_owner_asset_sha256:
|
|
description: 'SHA-256 digest of the commit-bound convergence owner record.'
|
|
required: false
|
|
default: ''
|
|
type: string
|
|
|
|
permissions:
|
|
contents: read
|
|
actions: read
|
|
|
|
# GitHub environments gate secret access and deployment admission, but do not
|
|
# serialize jobs. Share one resource lock with the recovery workflow so two
|
|
# release convergences, a verification run, and emergency recovery can never
|
|
# operate on the single stable demo host concurrently.
|
|
concurrency:
|
|
group: stable-demo-runtime
|
|
queue: max
|
|
cancel-in-progress: false
|
|
|
|
jobs:
|
|
resolve:
|
|
runs-on: ubuntu-24.04
|
|
timeout-minutes: 10
|
|
outputs:
|
|
tag: ${{ steps.target.outputs.tag }}
|
|
target: ${{ steps.target.outputs.target }}
|
|
environment_name: ${{ steps.target.outputs.environment_name }}
|
|
skip: ${{ steps.target.outputs.skip || 'false' }}
|
|
mock_nodes: ${{ steps.runtime_profile.outputs.mock_nodes }}
|
|
mock_vms_per_node: ${{ steps.runtime_profile.outputs.mock_vms_per_node }}
|
|
mock_lxcs_per_node: ${{ steps.runtime_profile.outputs.mock_lxcs_per_node }}
|
|
mock_docker_hosts: ${{ steps.runtime_profile.outputs.mock_docker_hosts }}
|
|
mock_docker_containers: ${{ steps.runtime_profile.outputs.mock_docker_containers }}
|
|
mock_generic_hosts: ${{ steps.runtime_profile.outputs.mock_generic_hosts }}
|
|
mock_k8s_clusters: ${{ steps.runtime_profile.outputs.mock_k8s_clusters }}
|
|
mock_k8s_nodes: ${{ steps.runtime_profile.outputs.mock_k8s_nodes }}
|
|
mock_k8s_pods: ${{ steps.runtime_profile.outputs.mock_k8s_pods }}
|
|
mock_k8s_deployments: ${{ steps.runtime_profile.outputs.mock_k8s_deployments }}
|
|
mock_seed_duration: ${{ steps.runtime_profile.outputs.mock_seed_duration }}
|
|
mock_sample_interval: ${{ steps.runtime_profile.outputs.mock_sample_interval }}
|
|
mock_update_interval: ${{ steps.runtime_profile.outputs.mock_update_interval }}
|
|
|
|
steps:
|
|
- name: Checkout repository
|
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
with:
|
|
persist-credentials: false
|
|
fetch-depth: 0
|
|
fetch-tags: true
|
|
|
|
- name: Resolve target tag and demo environment
|
|
id: target
|
|
env:
|
|
INPUT_TAG: ${{ inputs.tag }}
|
|
INPUT_TARGET: ${{ inputs.target }}
|
|
VERIFY_ONLY: ${{ inputs.verify_only }}
|
|
GH_TOKEN: ${{ github.token }}
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
TAG="$INPUT_TAG"
|
|
REQUESTED_TARGET="$INPUT_TARGET"
|
|
if [ -z "$TAG" ]; then
|
|
echo "::error::A stable release tag is required."
|
|
exit 1
|
|
fi
|
|
if [ "$TAG" = "latest" ]; then
|
|
if [ "${VERIFY_ONLY:-false}" != "true" ]; then
|
|
echo "::error::The latest alias is allowed only for verification-only checks."
|
|
exit 1
|
|
fi
|
|
TAG="$(gh api "repos/${{ github.repository }}/releases/latest" --jq '.tag_name')"
|
|
echo "Resolved verification-only target to latest stable release ${TAG}."
|
|
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
|
|
python3 scripts/write_github_output.py tag "$TAG"
|
|
echo "target=stable" >> "$GITHUB_OUTPUT"
|
|
echo "environment_name=demo-stable" >> "$GITHUB_OUTPUT"
|
|
echo "skip=true" >> "$GITHUB_OUTPUT"
|
|
echo "Prerelease demo updates are retired after v6 GA; skipping public demo update for ${TAG}."
|
|
exit 0
|
|
fi
|
|
TARGET="stable"
|
|
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"
|
|
;;
|
|
*)
|
|
echo "::error::Unsupported demo target: ${TARGET}"
|
|
exit 1
|
|
;;
|
|
esac
|
|
|
|
python3 scripts/write_github_output.py tag "$TAG"
|
|
python3 scripts/write_github_output.py target "$TARGET"
|
|
echo "environment_name=$ENVIRONMENT_NAME" >> "$GITHUB_OUTPUT"
|
|
echo "Resolved demo deployment: tag=${TAG}, target=${TARGET}, environment=${ENVIRONMENT_NAME}"
|
|
|
|
- name: Validate governed release line for selected tag
|
|
env:
|
|
WORKFLOW_OUTPUT_1: ${{ steps.target.outputs.tag }}
|
|
run: |
|
|
set -euo pipefail
|
|
TAG="${WORKFLOW_OUTPUT_1}"
|
|
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: Resolve target-compatible demo runtime profile
|
|
id: runtime_profile
|
|
env:
|
|
WORKFLOW_OUTPUT_1: ${{ steps.target.outputs.tag }}
|
|
run: |
|
|
set -euo pipefail
|
|
TAG="${WORKFLOW_OUTPUT_1}"
|
|
TAG_REF="refs/tags/${TAG}"
|
|
PROFILE_OUTPUT="$(bash .github/scripts/resolve-demo-runtime-profile.sh "${TAG_REF}")"
|
|
while IFS='=' read -r output_name output_value; do
|
|
python3 scripts/write_github_output.py "$output_name" "$output_value"
|
|
done <<< "$PROFILE_OUTPUT"
|
|
printf 'Resolved target-compatible demo runtime profile for %s:\n%s\n' "$TAG" "$PROFILE_OUTPUT"
|
|
|
|
update-demo:
|
|
needs: resolve
|
|
if: needs.resolve.outputs.skip != 'true'
|
|
runs-on: ubuntu-24.04
|
|
timeout-minutes: 90
|
|
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
|
|
env:
|
|
VERIFY_ONLY: ${{ inputs.verify_only }}
|
|
WORKFLOW_OUTPUT_1: ${{ needs.resolve.outputs.tag }}
|
|
WORKFLOW_OUTPUT_2: ${{ needs.resolve.outputs.target }}
|
|
WORKFLOW_OUTPUT_3: ${{ needs.resolve.outputs.environment_name }}
|
|
run: |
|
|
echo "Tag: ${WORKFLOW_OUTPUT_1}"
|
|
echo "Target: ${WORKFLOW_OUTPUT_2}"
|
|
echo "Environment: ${WORKFLOW_OUTPUT_3}"
|
|
echo "Verification only: ${VERIFY_ONLY}"
|
|
|
|
- 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: Require exact committed activation marker for mutation
|
|
if: inputs.verify_only != true
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
TAG: ${{ needs.resolve.outputs.tag }}
|
|
CONVERGENCE_RUN_ID: ${{ inputs.activation_convergence_run_id }}
|
|
LEASE_SHA: ${{ inputs.customer_promotion_lease_sha }}
|
|
OWNER_ASSET_NAME: ${{ inputs.convergence_owner_asset_name }}
|
|
OWNER_ASSET_SHA256: ${{ inputs.convergence_owner_asset_sha256 }}
|
|
run: |
|
|
set -euo pipefail
|
|
if [[ ! "${TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || \
|
|
[[ ! "${CONVERGENCE_RUN_ID}" =~ ^[0-9]+$ ]] || \
|
|
[[ ! "${LEASE_SHA}" =~ ^[0-9a-f]{40}$ ]] || \
|
|
[[ ! "${OWNER_ASSET_NAME}" =~ ^release-convergence-owner-${CONVERGENCE_RUN_ID}-[1-9][0-9]*\.json$ ]] || \
|
|
[[ ! "${OWNER_ASSET_SHA256}" =~ ^[0-9a-f]{64}$ ]]; then
|
|
echo "::error::Stable demo mutation requires an exact stable tag, convergence owner, and lease."
|
|
exit 1
|
|
fi
|
|
release_state="$(
|
|
gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}" \
|
|
--jq '[.tag_name, (.draft | tostring), (.prerelease | tostring), (.immutable // false | tostring), (.published_at // "")] | @tsv'
|
|
)"
|
|
if [ "$(awk -F '\t' '{print $1}' <<<"${release_state}")" != "${TAG}" ] || \
|
|
[ "$(awk -F '\t' '{print $2}' <<<"${release_state}")" != "false" ] || \
|
|
[ "$(awk -F '\t' '{print $3}' <<<"${release_state}")" != "false" ] || \
|
|
[ "$(awk -F '\t' '{print $4}' <<<"${release_state}")" != "true" ] || \
|
|
[ -z "$(awk -F '\t' '{print $5}' <<<"${release_state}")" ]; then
|
|
echo "::error::Stable demo mutation refuses mutable, inactive, or prerelease tag ${TAG}."
|
|
exit 1
|
|
fi
|
|
marker="$(mktemp)"
|
|
owner_record="$(mktemp)"
|
|
curl -fsSL --retry 4 --retry-delay 2 --retry-all-errors \
|
|
-o "${marker}" \
|
|
"https://github.com/${{ github.repository }}/releases/download/${TAG}/release-activation.json"
|
|
jq -e \
|
|
--arg tag "${TAG}" \
|
|
'.schema_version == 1 and .tag == $tag and (.convergence_run_id | test("^[0-9]+$"))' \
|
|
"${marker}" >/dev/null
|
|
activation_owner_run_id="$(jq -r '.convergence_run_id' "${marker}")"
|
|
activation_marker_sha256="$(sha256sum "${marker}" | awk '{print $1}')"
|
|
observed_lease_sha="$(
|
|
gh api \
|
|
"repos/${{ github.repository }}/git/ref/heads/release-customer-promotion-lock" \
|
|
--jq '.object.sha'
|
|
)"
|
|
if [ "${observed_lease_sha}" != "${LEASE_SHA}" ]; then
|
|
echo "::error::Stable demo lease is ${observed_lease_sha:-absent}, expected ${LEASE_SHA}."
|
|
exit 1
|
|
fi
|
|
curl -fsSL --retry 4 --retry-delay 2 --retry-all-errors \
|
|
-o "${owner_record}" \
|
|
"https://raw.githubusercontent.com/${{ github.repository }}/${LEASE_SHA}/${OWNER_ASSET_NAME}"
|
|
printf '%s %s\n' "${OWNER_ASSET_SHA256}" "${owner_record}" | sha256sum --check --
|
|
jq -e \
|
|
--arg tag "${TAG}" \
|
|
--arg activation_owner_run_id "${activation_owner_run_id}" \
|
|
--arg activation_marker_sha256 "${activation_marker_sha256}" \
|
|
--arg convergence_run_id "${CONVERGENCE_RUN_ID}" \
|
|
--arg owner_asset_name "${OWNER_ASSET_NAME}" \
|
|
'.schema_version == 2 and .tag == $tag and .activation_owner_run_id == $activation_owner_run_id and .activation_marker_sha256 == $activation_marker_sha256 and .convergence_run_id == $convergence_run_id and .owner_asset_name == $owner_asset_name' \
|
|
"${owner_record}" >/dev/null
|
|
rm -f "${marker}" "${owner_record}"
|
|
echo "[OK] Stable demo mutation is bound to committed ${TAG} convergence run ${CONVERGENCE_RUN_ID}."
|
|
|
|
- name: Checkout repository
|
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
with:
|
|
persist-credentials: false
|
|
fetch-depth: 0
|
|
fetch-tags: true
|
|
|
|
- name: Set up Go
|
|
if: inputs.verify_only != true
|
|
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
|
with:
|
|
go-version-file: go.mod
|
|
cache: false
|
|
|
|
- name: Wait for release assets
|
|
if: inputs.verify_only != true
|
|
env:
|
|
WORKFLOW_OUTPUT_1: ${{ needs.resolve.outputs.tag }}
|
|
run: |
|
|
set -euo pipefail
|
|
TAG="${WORKFLOW_OUTPUT_1}"
|
|
echo "Waiting for activated 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: Verify signed release and select only demo server runtime
|
|
if: inputs.verify_only != true
|
|
env:
|
|
PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }}
|
|
EXPECTED_VERSION: ${{ needs.resolve.outputs.tag }}
|
|
run: |
|
|
set -euo pipefail
|
|
TRUSTED_SSH_PUBLIC_KEY="$(
|
|
go run ./scripts/release_update_key.go public-key-ssh \
|
|
--public-key "${PULSE_UPDATE_SIGNING_PUBLIC_KEY}" \
|
|
--comment pulse-installer
|
|
)"
|
|
asset="pulse-${EXPECTED_VERSION}-linux-amd64.tar.gz"
|
|
curl --disable --fail --location --silent --show-error --max-time 300 \
|
|
-o /tmp/pulse-demo-release.tgz "https://github.com/rcourtman/Pulse/releases/download/${EXPECTED_VERSION}/${asset}"
|
|
curl --disable --fail --location --silent --show-error --max-time 30 \
|
|
-o /tmp/pulse-demo-release.sshsig "https://github.com/rcourtman/Pulse/releases/download/${EXPECTED_VERSION}/${asset}.sshsig"
|
|
printf 'pulse-installer %s\n' "$TRUSTED_SSH_PUBLIC_KEY" > /tmp/pulse-demo-signers
|
|
ssh-keygen -Y verify -f /tmp/pulse-demo-signers -I pulse-installer \
|
|
-n pulse-install -s /tmp/pulse-demo-release.sshsig < /tmp/pulse-demo-release.tgz
|
|
python3 .github/scripts/dispatch-demo-runtime.py prepare
|
|
|
|
- name: Tailscale
|
|
id: tailscale
|
|
uses: tailscale/github-action@780049a30b6ff5c378a9e7b389d15ece7a204888 # v4.1.3
|
|
with:
|
|
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
|
|
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
|
|
tags: tag:infra
|
|
version: '1.94.2'
|
|
ping: ${{ secrets.DEMO_SERVER_HOST }}
|
|
|
|
- name: Diagnose Tailscale setup failure
|
|
if: failure() && steps.tailscale.outcome == 'failure'
|
|
env:
|
|
DEMO_SERVER_HOST: ${{ secrets.DEMO_SERVER_HOST }}
|
|
run: bash .github/scripts/check-demo-reachability.sh diagnose
|
|
|
|
- name: Verify demo network path
|
|
env:
|
|
DEMO_SERVER_HOST: ${{ secrets.DEMO_SERVER_HOST }}
|
|
run: bash .github/scripts/check-demo-reachability.sh
|
|
|
|
- name: Setup SSH
|
|
env:
|
|
DEMO_SERVER_HOST: ${{ secrets.DEMO_SERVER_HOST }}
|
|
DEMO_SERVER_SSH_KEY: ${{ secrets.DEMO_SERVER_SSH_KEY }}
|
|
run: bash .github/scripts/setup-demo-ssh.sh
|
|
|
|
- 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
|
|
env:
|
|
WORKFLOW_OUTPUT_1: ${{ needs.resolve.outputs.target }}
|
|
run: |
|
|
set -euo pipefail
|
|
TARGET="${WORKFLOW_OUTPUT_1}"
|
|
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
|
|
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 }}
|
|
WORKFLOW_OUTPUT_1: ${{ needs.resolve.outputs.tag }}
|
|
run: |
|
|
set -euo pipefail
|
|
TARGET="${WORKFLOW_OUTPUT_1}"
|
|
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: Refuse mutation during verification-only checks
|
|
if: inputs.verify_only == true && steps.current.outputs.skip_current != 'true'
|
|
run: |
|
|
echo "::error::Verification-only check found a demo version mismatch; refusing to update the host."
|
|
exit 1
|
|
|
|
- name: Apply guarded demo transaction
|
|
if: inputs.verify_only != true
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
DEMO_SERVER_HOST: ${{ secrets.DEMO_SERVER_HOST }}
|
|
DEMO_SERVER_USER: ${{ secrets.DEMO_SERVER_USER }}
|
|
EXPECTED_VERSION: ${{ needs.resolve.outputs.tag }}
|
|
CURRENT_MATCHES: ${{ steps.current.outputs.skip_current }}
|
|
MOCK_NODES: ${{ needs.resolve.outputs.mock_nodes }}
|
|
MOCK_VMS_PER_NODE: ${{ needs.resolve.outputs.mock_vms_per_node }}
|
|
MOCK_LXCS_PER_NODE: ${{ needs.resolve.outputs.mock_lxcs_per_node }}
|
|
MOCK_DOCKER_HOSTS: ${{ needs.resolve.outputs.mock_docker_hosts }}
|
|
MOCK_DOCKER_CONTAINERS: ${{ needs.resolve.outputs.mock_docker_containers }}
|
|
MOCK_GENERIC_HOSTS: ${{ needs.resolve.outputs.mock_generic_hosts }}
|
|
MOCK_K8S_CLUSTERS: ${{ needs.resolve.outputs.mock_k8s_clusters }}
|
|
MOCK_K8S_NODES: ${{ needs.resolve.outputs.mock_k8s_nodes }}
|
|
MOCK_K8S_PODS: ${{ needs.resolve.outputs.mock_k8s_pods }}
|
|
MOCK_K8S_DEPLOYMENTS: ${{ needs.resolve.outputs.mock_k8s_deployments }}
|
|
MOCK_SEED_DURATION: ${{ needs.resolve.outputs.mock_seed_duration }}
|
|
MOCK_SAMPLE_INTERVAL: ${{ needs.resolve.outputs.mock_sample_interval }}
|
|
MOCK_UPDATE_INTERVAL: ${{ needs.resolve.outputs.mock_update_interval }}
|
|
run: |
|
|
set -euo pipefail
|
|
MODE=update
|
|
if [ "$CURRENT_MATCHES" = "true" ]; then MODE=recover; fi
|
|
python3 .github/scripts/dispatch-demo-runtime.py "$MODE" > demo-runtime-transaction.json
|
|
jq -e '.schema_version == 2 and (.status == "committed" or .status == "healthy_noop") and .forward.elapsed_seconds >= 300' demo-runtime-transaction.json
|
|
|
|
- name: Retain guarded demo transaction evidence
|
|
if: always() && inputs.verify_only != true
|
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
with:
|
|
name: stable-demo-transaction-${{ github.run_id }}
|
|
path: demo-runtime-transaction.json
|
|
if-no-files-found: warn
|
|
retention-days: 14
|
|
|
|
- name: Verify demo runtime
|
|
env:
|
|
DEMO_SERVER_HOST: ${{ secrets.DEMO_SERVER_HOST }}
|
|
DEMO_SERVER_USER: ${{ secrets.DEMO_SERVER_USER }}
|
|
WORKFLOW_OUTPUT_1: ${{ needs.resolve.outputs.tag }}
|
|
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="${WORKFLOW_OUTPUT_1}"
|
|
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}"
|
|
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="$(mktemp)"
|
|
trap 'rm -f "$COOKIE_FILE"' EXIT
|
|
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/license/runtime-capabilities" >/dev/null
|
|
|
|
MOCK_ENABLED="false"
|
|
RESOURCES="0"
|
|
for attempt in $(seq 1 30); do
|
|
MOCK_ENABLED=$(curl -fsS -b "$COOKIE_FILE" "${DEMO_LOCAL_BASE_URL}/api/system/mock-mode" | jq -r '.enabled // false')
|
|
RESOURCES=$(curl -fsS -b "$COOKIE_FILE" "${DEMO_LOCAL_BASE_URL}/api/state" | jq -r '.resources | if type == "array" then length else 0 end')
|
|
if [ "$MOCK_ENABLED" = "true" ] && [ "$RESOURCES" -ge 1 ]; then
|
|
break
|
|
fi
|
|
sleep 2
|
|
done
|
|
|
|
echo "Mock mode enabled: $MOCK_ENABLED"
|
|
echo "Mock resources detected: $RESOURCES"
|
|
|
|
if [ "$MOCK_ENABLED" != "true" ]; then
|
|
echo "::error::Demo server mock mode did not enable after entitlement sync"
|
|
exit 1
|
|
fi
|
|
if [ "$RESOURCES" -lt 1 ]; then
|
|
echo "::error::Demo server updated but canonical mock resources are missing"
|
|
exit 1
|
|
fi
|
|
EOF
|
|
|
|
echo "Demo server successfully updated and verified."
|
|
|
|
- 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
|
|
export PULSE_DEMO_AUTH_USER="${DEMO_AUTH_USER:-demo}"
|
|
export PULSE_DEMO_AUTH_PASS="${DEMO_AUTH_PASS:-demo}"
|
|
./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
|