mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-08-25 17:02:10 +00:00
1367 lines
58 KiB
YAML
1367 lines
58 KiB
YAML
name: Pulse Release Pipeline
|
|
# Optimized: parallel jobs, fast prerelease path
|
|
|
|
on:
|
|
workflow_dispatch:
|
|
inputs:
|
|
version:
|
|
description: 'Version number (e.g., 4.30.0)'
|
|
required: true
|
|
type: string
|
|
release_notes:
|
|
description: 'Release notes (markdown)'
|
|
required: true
|
|
type: string
|
|
promoted_from_tag:
|
|
description: 'Stable only: prerelease tag being promoted (for example 6.0.0-rc.2)'
|
|
required: false
|
|
type: string
|
|
rollback_version:
|
|
description: 'Required: prior stable version to pin for rollback (for example 5.1.14 or v5.1.14)'
|
|
required: true
|
|
type: string
|
|
ga_date:
|
|
description: 'First stable v6.0.0 GA only: exact GA publish date (YYYY-MM-DD)'
|
|
required: false
|
|
type: string
|
|
v5_eos_date:
|
|
description: 'First stable v6.0.0 GA only: Pulse v5 end-of-support date (YYYY-MM-DD)'
|
|
required: false
|
|
type: string
|
|
hotfix_exception:
|
|
description: 'Stable only: bypass the 72-hour prerelease soak for urgent customer harm'
|
|
required: false
|
|
type: boolean
|
|
default: false
|
|
hotfix_reason:
|
|
description: 'Stable only: reason for hotfix soak exception'
|
|
required: false
|
|
type: string
|
|
historical_asset_backfill_only:
|
|
description: 'Repair an already-published release packet in place without rebuilding binaries'
|
|
required: false
|
|
type: boolean
|
|
default: false
|
|
draft_only:
|
|
description: 'Create draft release only (do not publish)'
|
|
required: false
|
|
type: boolean
|
|
default: false
|
|
mobile_release_decision:
|
|
description: 'Required mobile impact decision: no-mobile-impact, existing-mobile-build-compatible, mobile-candidate-uploaded, or mobile-candidate-required'
|
|
required: true
|
|
type: string
|
|
mobile_release_evidence:
|
|
description: 'Evidence for existing-mobile-build-compatible or mobile-candidate-uploaded decisions'
|
|
required: false
|
|
type: string
|
|
|
|
concurrency:
|
|
group: release-${{ github.event.inputs.version || github.ref || github.run_id }}
|
|
cancel-in-progress: false
|
|
|
|
permissions:
|
|
actions: read
|
|
contents: read
|
|
|
|
jobs:
|
|
# Combined version extraction and validation (saves a checkout)
|
|
prepare:
|
|
runs-on: ubuntu-24.04
|
|
timeout-minutes: 5
|
|
outputs:
|
|
version: ${{ steps.extract.outputs.version }}
|
|
tag: ${{ steps.extract.outputs.tag }}
|
|
is_prerelease: ${{ steps.extract.outputs.is_prerelease }}
|
|
source_branch: ${{ steps.extract.outputs.source_branch }}
|
|
required_branch: ${{ steps.branch_policy.outputs.required_branch }}
|
|
promoted_from_tag: ${{ steps.promotion.outputs.promoted_from_tag }}
|
|
rollback_tag: ${{ steps.promotion.outputs.rollback_tag }}
|
|
rollback_command: ${{ steps.promotion.outputs.rollback_command }}
|
|
ga_date: ${{ steps.promotion.outputs.ga_date }}
|
|
v5_eos_date: ${{ steps.promotion.outputs.v5_eos_date }}
|
|
hotfix_exception: ${{ steps.promotion.outputs.hotfix_exception }}
|
|
hotfix_reason: ${{ steps.promotion.outputs.hotfix_reason }}
|
|
promotion_mode: ${{ steps.promotion.outputs.promotion_mode }}
|
|
is_stable_patch: ${{ steps.promotion.outputs.is_stable_patch }}
|
|
historical_asset_backfill_only: ${{ steps.extract.outputs.historical_asset_backfill_only }}
|
|
steps:
|
|
- name: Extract version
|
|
id: extract
|
|
run: |
|
|
VERSION=$(jq -r '.inputs.version // ""' "$GITHUB_EVENT_PATH" 2>/dev/null || echo "")
|
|
if [ -z "$VERSION" ]; then
|
|
echo "::error::workflow_dispatch must include a version input"
|
|
exit 1
|
|
fi
|
|
TAG="v${VERSION}"
|
|
|
|
IS_PRERELEASE="false"
|
|
if [[ "$VERSION" =~ -rc\.[0-9]+$ ]] || [[ "$VERSION" =~ -alpha\.[0-9]+$ ]] || [[ "$VERSION" =~ -beta\.[0-9]+$ ]]; then
|
|
IS_PRERELEASE="true"
|
|
echo "Detected prerelease version: ${VERSION}"
|
|
fi
|
|
|
|
if [[ "${GITHUB_REF}" != refs/heads/* ]]; then
|
|
echo "::error::Release workflow must be dispatched from a branch ref (current ref: ${GITHUB_REF})."
|
|
exit 1
|
|
fi
|
|
|
|
SOURCE_BRANCH="${GITHUB_REF_NAME}"
|
|
HISTORICAL_ASSET_BACKFILL_ONLY=$(jq -r '.inputs.historical_asset_backfill_only // "false"' "$GITHUB_EVENT_PATH" 2>/dev/null || echo "false")
|
|
echo "tag=${TAG}" >> $GITHUB_OUTPUT
|
|
echo "version=${VERSION}" >> $GITHUB_OUTPUT
|
|
echo "is_prerelease=${IS_PRERELEASE}" >> $GITHUB_OUTPUT
|
|
echo "source_branch=${SOURCE_BRANCH}" >> $GITHUB_OUTPUT
|
|
echo "historical_asset_backfill_only=${HISTORICAL_ASSET_BACKFILL_ONLY}" >> $GITHUB_OUTPUT
|
|
echo "Version: ${VERSION}, Tag: ${TAG}, Prerelease: ${IS_PRERELEASE}, Branch: ${SOURCE_BRANCH}, HistoricalBackfillOnly: ${HISTORICAL_ASSET_BACKFILL_ONLY}"
|
|
|
|
- name: Checkout repository
|
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
|
with:
|
|
fetch-depth: 0
|
|
sparse-checkout: |
|
|
VERSION
|
|
docs/release-control/control_plane.json
|
|
scripts/release_control/control_plane.py
|
|
scripts/release_control/mobile_release_gate.py
|
|
scripts/release_control/repo_file_io.py
|
|
|
|
- name: Resolve required release branch
|
|
id: branch_policy
|
|
run: |
|
|
REQUIRED_BRANCH="$(python3 scripts/release_control/control_plane.py --branch-for-version "${{ steps.extract.outputs.version }}")"
|
|
if [ "${{ steps.extract.outputs.source_branch }}" != "$REQUIRED_BRANCH" ]; then
|
|
echo "::error::Invalid release line. Version ${{ steps.extract.outputs.version }} must run from ${REQUIRED_BRANCH}, but workflow ref is ${{ steps.extract.outputs.source_branch }}."
|
|
exit 1
|
|
fi
|
|
echo "required_branch=${REQUIRED_BRANCH}" >> "$GITHUB_OUTPUT"
|
|
echo "[OK] Governed release branch for ${{ steps.extract.outputs.version }} is ${REQUIRED_BRANCH}"
|
|
|
|
- name: Validate VERSION file
|
|
if: ${{ steps.extract.outputs.historical_asset_backfill_only != 'true' }}
|
|
run: |
|
|
FILE_VERSION=$(cat VERSION | tr -d '\n')
|
|
REQUESTED_VERSION="${{ steps.extract.outputs.version }}"
|
|
if [ "$FILE_VERSION" != "$REQUESTED_VERSION" ]; then
|
|
echo "::error::VERSION file ($FILE_VERSION) does not match requested version ($REQUESTED_VERSION)."
|
|
echo "The VERSION file must be updated and committed before running release."
|
|
exit 1
|
|
fi
|
|
echo "[OK] VERSION file matches requested version ($REQUESTED_VERSION)"
|
|
|
|
- name: Validate mobile release decision
|
|
if: ${{ steps.extract.outputs.historical_asset_backfill_only != 'true' }}
|
|
env:
|
|
MOBILE_RELEASE_DECISION: ${{ github.event.inputs.mobile_release_decision }}
|
|
MOBILE_RELEASE_EVIDENCE: ${{ github.event.inputs.mobile_release_evidence }}
|
|
run: |
|
|
set -euo pipefail
|
|
python3 scripts/release_control/mobile_release_gate.py \
|
|
--version "${{ steps.extract.outputs.version }}" \
|
|
--decision "${MOBILE_RELEASE_DECISION}" \
|
|
--evidence "${MOBILE_RELEASE_EVIDENCE}" \
|
|
--github-annotations
|
|
|
|
- name: Validate promotion policy
|
|
if: ${{ steps.extract.outputs.historical_asset_backfill_only != 'true' }}
|
|
id: promotion
|
|
env:
|
|
VERSION: ${{ steps.extract.outputs.version }}
|
|
TAG: ${{ steps.extract.outputs.tag }}
|
|
REQUIRED_BRANCH: ${{ steps.branch_policy.outputs.required_branch }}
|
|
IS_PRERELEASE: ${{ steps.extract.outputs.is_prerelease }}
|
|
PROMOTED_FROM_TAG_INPUT: ${{ github.event.inputs.promoted_from_tag }}
|
|
ROLLBACK_VERSION_INPUT: ${{ github.event.inputs.rollback_version }}
|
|
GA_DATE_INPUT: ${{ github.event.inputs.ga_date }}
|
|
V5_EOS_DATE_INPUT: ${{ github.event.inputs.v5_eos_date }}
|
|
HOTFIX_EXCEPTION_INPUT: ${{ github.event.inputs.hotfix_exception }}
|
|
HOTFIX_REASON_INPUT: ${{ github.event.inputs.hotfix_reason }}
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
git fetch --prune origin main "${REQUIRED_BRANCH}" --tags
|
|
|
|
RELEASE_NOTES_INPUT="$(jq -r '.inputs.release_notes // ""' "$GITHUB_EVENT_PATH")"
|
|
NOTES_FILE="$(mktemp)"
|
|
printf '%s\n' "$RELEASE_NOTES_INPUT" > "$NOTES_FILE"
|
|
|
|
HELPER_ARGS=(
|
|
--version "${VERSION}"
|
|
--promoted-from-tag "${PROMOTED_FROM_TAG_INPUT:-}"
|
|
--rollback-version "${ROLLBACK_VERSION_INPUT:-}"
|
|
--ga-date "${GA_DATE_INPUT:-}"
|
|
--v5-eos-date "${V5_EOS_DATE_INPUT:-}"
|
|
--hotfix-reason "${HOTFIX_REASON_INPUT:-}"
|
|
--release-notes-file "$NOTES_FILE"
|
|
)
|
|
if [ "${HOTFIX_EXCEPTION_INPUT:-false}" = "true" ]; then
|
|
HELPER_ARGS+=(--hotfix-exception)
|
|
fi
|
|
|
|
python3 scripts/release_control/resolve_release_promotion.py "${HELPER_ARGS[@]}" > "$RUNNER_TEMP/promotion-metadata.out"
|
|
rm -f "$NOTES_FILE"
|
|
|
|
{
|
|
cat "$RUNNER_TEMP/promotion-metadata.out"
|
|
} >> "$GITHUB_OUTPUT"
|
|
|
|
echo "[OK] Promotion policy validated for ${TAG}"
|
|
|
|
build_release_candidate:
|
|
name: Build Immutable Release Candidate
|
|
needs: prepare
|
|
if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' }}
|
|
permissions:
|
|
contents: read
|
|
uses: ./.github/workflows/build-release-candidate.yml
|
|
secrets: inherit
|
|
with:
|
|
version: ${{ needs.prepare.outputs.version }}
|
|
require_macos_signing: true
|
|
require_windows_signing: ${{ needs.prepare.outputs.is_prerelease != 'true' }}
|
|
|
|
# Frontend checks run in parallel with backend tests
|
|
frontend_checks:
|
|
needs: prepare
|
|
if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' }}
|
|
runs-on: ubuntu-24.04
|
|
timeout-minutes: 10
|
|
steps:
|
|
- name: Checkout repository
|
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
|
|
|
- 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 dependencies
|
|
run: npm --prefix frontend-modern ci
|
|
|
|
- name: Lint frontend
|
|
run: npm --prefix frontend-modern run lint
|
|
|
|
- name: Audit header composition
|
|
run: npm --prefix frontend-modern run lint:headers
|
|
|
|
- name: Check frontend copy-paste duplication
|
|
run: npm --prefix frontend-modern run lint:cpd
|
|
|
|
- name: Build verified frontend bundle
|
|
run: npm --prefix frontend-modern run build
|
|
|
|
- name: Upload verified frontend bundle
|
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
with:
|
|
name: release-frontend-${{ github.sha }}
|
|
path: frontend-modern/dist/
|
|
if-no-files-found: error
|
|
retention-days: 1
|
|
compression-level: 0
|
|
overwrite: true
|
|
|
|
# Backend tests run in parallel with frontend checks
|
|
backend_tests:
|
|
needs:
|
|
- prepare
|
|
- frontend_checks
|
|
if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' }}
|
|
runs-on: ubuntu-24.04
|
|
timeout-minutes: 30
|
|
env:
|
|
FRONTEND_DIST: frontend-modern/dist
|
|
steps:
|
|
- name: Checkout repository
|
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
|
|
|
- name: Download verified frontend bundle
|
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
with:
|
|
path: frontend-modern/dist
|
|
name: release-frontend-${{ github.sha }}
|
|
|
|
- name: Copy frontend to embed location
|
|
run: |
|
|
rm -rf internal/api/frontend-modern
|
|
mkdir -p internal/api/frontend-modern
|
|
cp -r frontend-modern/dist internal/api/frontend-modern/
|
|
|
|
- name: Set up Go
|
|
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
|
with:
|
|
go-version-file: go.mod
|
|
cache: true
|
|
|
|
- name: Run backend tests
|
|
env:
|
|
PULSE_DATA_DIR: /tmp/pulse-test-data
|
|
run: make test
|
|
|
|
# Docker build - amd64 only for prereleases, multi-arch for stable
|
|
docker_build:
|
|
needs: prepare
|
|
if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' }}
|
|
runs-on: ubuntu-24.04
|
|
timeout-minutes: 30
|
|
permissions:
|
|
contents: read
|
|
packages: write
|
|
steps:
|
|
- name: Checkout repository
|
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
|
|
|
- name: Set up QEMU
|
|
if: needs.prepare.outputs.is_prerelease != 'true'
|
|
uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0
|
|
|
|
- name: Set up Docker Buildx
|
|
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
|
|
|
- name: Log in to GHCR
|
|
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
|
with:
|
|
registry: ghcr.io
|
|
username: ${{ github.actor }}
|
|
password: ${{ secrets.GITHUB_TOKEN }}
|
|
|
|
- name: Derive license public key Docker cache key
|
|
id: license_key_cache
|
|
env:
|
|
PULSE_LICENSE_PUBLIC_KEY: ${{ secrets.PULSE_LICENSE_PUBLIC_KEY }}
|
|
run: |
|
|
set -euo pipefail
|
|
decoded_len="$(printf '%s' "${PULSE_LICENSE_PUBLIC_KEY}" | base64 -d | wc -c | tr -d ' ')"
|
|
if [ "${decoded_len}" != "32" ]; then
|
|
echo "PULSE_LICENSE_PUBLIC_KEY must decode to 32 bytes." >&2
|
|
exit 1
|
|
fi
|
|
key_sha256="$(printf '%s' "${PULSE_LICENSE_PUBLIC_KEY}" | base64 -d | sha256sum | awk '{print $1}')"
|
|
echo "sha256=${key_sha256}" >> "${GITHUB_OUTPUT}"
|
|
|
|
- name: Build Docker image (verify only)
|
|
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
|
with:
|
|
context: .
|
|
target: runtime
|
|
# amd64 only for prereleases (faster), multi-arch for stable releases
|
|
platforms: ${{ needs.prepare.outputs.is_prerelease == 'true' && 'linux/amd64' || 'linux/amd64,linux/arm64' }}
|
|
push: false # Don't push staging images, just verify build
|
|
provenance: mode=max
|
|
sbom: true
|
|
cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/pulse:buildcache
|
|
cache-to: type=registry,ref=ghcr.io/${{ github.repository_owner }}/pulse:buildcache,mode=max
|
|
build-args: |
|
|
VERSION=${{ needs.prepare.outputs.tag }}
|
|
PULSE_LICENSE_PUBLIC_KEY_SHA256=${{ steps.license_key_cache.outputs.sha256 }}
|
|
PULSE_UPDATE_SIGNING_PUBLIC_KEY=${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }}
|
|
secrets: |
|
|
pulse_license_public_key=${{ secrets.PULSE_LICENSE_PUBLIC_KEY }}
|
|
pulse_update_signing_key=${{ secrets.PULSE_UPDATE_SIGNING_KEY }}
|
|
|
|
- name: Build Pulse agent image (verify only)
|
|
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
|
with:
|
|
context: .
|
|
file: ./Dockerfile
|
|
target: agent_runtime
|
|
platforms: ${{ needs.prepare.outputs.is_prerelease == 'true' && 'linux/amd64' || 'linux/amd64,linux/arm64' }}
|
|
push: false
|
|
provenance: mode=max
|
|
sbom: true
|
|
cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/pulse-agent:buildcache
|
|
cache-to: type=registry,ref=ghcr.io/${{ github.repository_owner }}/pulse-agent:buildcache,mode=max
|
|
build-args: |
|
|
VERSION=${{ needs.prepare.outputs.tag }}
|
|
PULSE_LICENSE_PUBLIC_KEY_SHA256=${{ steps.license_key_cache.outputs.sha256 }}
|
|
PULSE_UPDATE_SIGNING_PUBLIC_KEY=${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }}
|
|
secrets: |
|
|
pulse_license_public_key=${{ secrets.PULSE_LICENSE_PUBLIC_KEY }}
|
|
pulse_update_signing_key=${{ secrets.PULSE_UPDATE_SIGNING_KEY }}
|
|
|
|
helm_smoke:
|
|
needs: prepare
|
|
if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' }}
|
|
runs-on: ubuntu-24.04
|
|
timeout-minutes: 30
|
|
steps:
|
|
- name: Checkout repository
|
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
|
|
|
- name: Set up Helm
|
|
uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 # v5.0.0
|
|
with:
|
|
version: v3.15.2
|
|
|
|
- name: Build local Pulse runtime image for Helm smoke
|
|
env:
|
|
DOCKER_BUILDKIT: 1
|
|
PULSE_LICENSE_PUBLIC_KEY: ${{ secrets.PULSE_LICENSE_PUBLIC_KEY }}
|
|
PULSE_UPDATE_SIGNING_KEY: ${{ secrets.PULSE_UPDATE_SIGNING_KEY }}
|
|
PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }}
|
|
run: |
|
|
PULSE_LICENSE_PUBLIC_KEY_SHA256="$(printf '%s' "${PULSE_LICENSE_PUBLIC_KEY}" | base64 -d | sha256sum | awk '{print $1}')"
|
|
docker build \
|
|
--target runtime \
|
|
--secret id=pulse_license_public_key,env=PULSE_LICENSE_PUBLIC_KEY \
|
|
--secret id=pulse_update_signing_key,env=PULSE_UPDATE_SIGNING_KEY \
|
|
--build-arg VERSION="${{ needs.prepare.outputs.tag }}" \
|
|
--build-arg PULSE_LICENSE_PUBLIC_KEY_SHA256="${PULSE_LICENSE_PUBLIC_KEY_SHA256}" \
|
|
--build-arg PULSE_UPDATE_SIGNING_PUBLIC_KEY="${PULSE_UPDATE_SIGNING_PUBLIC_KEY}" \
|
|
-t pulse-helm-smoke:${{ needs.prepare.outputs.version }} \
|
|
.
|
|
|
|
- name: Helm smoke test with local release-line image
|
|
env:
|
|
SMOKE_IMAGE_REPOSITORY: pulse-helm-smoke
|
|
SMOKE_IMAGE_TAG: ${{ needs.prepare.outputs.version }}
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
cleanup() {
|
|
kind delete cluster --name pulse-test >/dev/null 2>&1 || true
|
|
}
|
|
|
|
diagnose() {
|
|
echo "::group::helm status"
|
|
helm status pulse || true
|
|
echo "::endgroup::"
|
|
|
|
echo "::group::kubectl get all"
|
|
kubectl get all -A || true
|
|
echo "::endgroup::"
|
|
|
|
echo "::group::kubectl describe pods"
|
|
kubectl describe pods -A || true
|
|
echo "::endgroup::"
|
|
|
|
echo "::group::pod logs"
|
|
pods=$(kubectl get pods -A -o name 2>/dev/null || true)
|
|
for pod in $pods; do
|
|
echo "### ${pod}"
|
|
kubectl logs --all-containers=true --tail=200 "$pod" || true
|
|
done
|
|
echo "::endgroup::"
|
|
|
|
echo "::group::events"
|
|
kubectl get events -A --sort-by=.lastTimestamp || kubectl get events -A || true
|
|
echo "::endgroup::"
|
|
|
|
cleanup
|
|
}
|
|
|
|
trap 'diagnose' ERR
|
|
|
|
curl -Lo ./kind https://kind.sigs.k8s.io/dl/v0.20.0/kind-linux-amd64
|
|
chmod +x ./kind
|
|
sudo mv ./kind /usr/local/bin/kind
|
|
|
|
kind create cluster --name pulse-test --wait 5m
|
|
kind load docker-image "${SMOKE_IMAGE_REPOSITORY}:${SMOKE_IMAGE_TAG}" --name pulse-test
|
|
|
|
helm install pulse deploy/helm/pulse \
|
|
--set persistence.enabled=false \
|
|
--set server.secretEnv.create=true \
|
|
--set server.secretEnv.data.API_TOKENS=test-token \
|
|
--set image.repository="${SMOKE_IMAGE_REPOSITORY}" \
|
|
--set image.tag="${SMOKE_IMAGE_TAG}" \
|
|
--set image.pullPolicy=Never \
|
|
--wait --timeout 5m --debug
|
|
|
|
kubectl wait --for=condition=ready pod -l app.kubernetes.io/name=pulse --timeout=180s || (kubectl describe pods -l app.kubernetes.io/name=pulse && exit 1)
|
|
kubectl get pods -l app.kubernetes.io/name=pulse
|
|
|
|
helm upgrade pulse deploy/helm/pulse \
|
|
--set persistence.enabled=false \
|
|
--set server.secretEnv.create=true \
|
|
--set server.secretEnv.data.API_TOKENS=test-token \
|
|
--set image.repository="${SMOKE_IMAGE_REPOSITORY}" \
|
|
--set image.tag="${SMOKE_IMAGE_TAG}" \
|
|
--set image.pullPolicy=Never \
|
|
--wait --timeout 5m --debug
|
|
|
|
trap - ERR
|
|
cleanup
|
|
|
|
echo "✓ Helm smoke test passed"
|
|
|
|
# Integration tests - skipped for prereleases (they've been tested in CI)
|
|
integration_tests:
|
|
needs:
|
|
- prepare
|
|
- frontend_checks
|
|
if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' && needs.prepare.outputs.is_prerelease != 'true' }}
|
|
runs-on: ubuntu-24.04
|
|
timeout-minutes: 45
|
|
env:
|
|
FRONTEND_DIST: frontend-modern/dist
|
|
steps:
|
|
- name: Checkout repository
|
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
|
|
|
- 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: Download verified frontend bundle
|
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
with:
|
|
path: frontend-modern/dist
|
|
name: release-frontend-${{ github.sha }}
|
|
|
|
- name: Copy frontend to embed location
|
|
run: |
|
|
rm -rf internal/api/frontend-modern
|
|
mkdir -p internal/api/frontend-modern
|
|
cp -r frontend-modern/dist internal/api/frontend-modern/
|
|
|
|
- name: Set up Go
|
|
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
|
with:
|
|
go-version-file: go.mod
|
|
cache: true
|
|
|
|
- name: Build Pulse Docker image for integration tests
|
|
run: docker build -t pulse:test --target runtime .
|
|
|
|
- name: Build mock GitHub server
|
|
run: docker build -t pulse-mock-github:test tests/integration/mock-github-server
|
|
|
|
- name: Install integration test dependencies
|
|
working-directory: tests/integration
|
|
run: |
|
|
npm ci
|
|
npx playwright install --with-deps chromium
|
|
|
|
- name: Run integration tests
|
|
working-directory: tests/integration
|
|
env:
|
|
MOCK_CHECKSUM_ERROR: "false"
|
|
MOCK_NETWORK_ERROR: "false"
|
|
MOCK_RATE_LIMIT: "false"
|
|
MOCK_STALE_RELEASE: "false"
|
|
PULSE_MULTI_TENANT_ENABLED: "true"
|
|
PULSE_E2E_ENTITLEMENT_PROFILE: "multi-tenant"
|
|
PULSE_E2E_BOOTSTRAP_TOKEN: 0123456789abcdef0123456789abcdef0123456789abcdef
|
|
run: |
|
|
docker compose -f docker-compose.test.yml up -d
|
|
|
|
echo "Waiting for services to be healthy..."
|
|
timeout 60 sh -c 'until docker inspect --format="{{json .State.Health.Status}}" pulse-mock-github | grep -q "healthy"; do sleep 2; done'
|
|
timeout 60 sh -c 'until docker inspect --format="{{json .State.Health.Status}}" pulse-test-server | grep -q "healthy"; do sleep 2; done'
|
|
|
|
for i in 1 2 3 4 5; do
|
|
if curl -f -s http://localhost:7655/api/health > /dev/null 2>&1; then
|
|
echo "Pulse server is reachable"
|
|
break
|
|
elif [ $i -eq 5 ]; then
|
|
docker logs pulse-test-server || true
|
|
exit 1
|
|
fi
|
|
sleep 2
|
|
done
|
|
|
|
node scripts/apply-entitlement-profile.mjs
|
|
|
|
echo "Validating seeded bootstrap token..."
|
|
BOOTSTRAP_STATUS=$(curl -s -o /tmp/bootstrap-token-validation.txt -w "%{http_code}" \
|
|
-X POST \
|
|
-H "Content-Type: application/json" \
|
|
--data "{\"token\":\"${PULSE_E2E_BOOTSTRAP_TOKEN}\"}" \
|
|
http://localhost:7655/api/security/validate-bootstrap-token || true)
|
|
echo "Bootstrap token validation endpoint returned HTTP ${BOOTSTRAP_STATUS}"
|
|
if [ "${BOOTSTRAP_STATUS}" != "204" ]; then
|
|
cat /tmp/bootstrap-token-validation.txt || true
|
|
docker logs pulse-test-server || true
|
|
exit 1
|
|
fi
|
|
|
|
echo "Running update API route smoke check..."
|
|
STATUS=$(curl -s -o /tmp/update-status.json -w "%{http_code}" http://localhost:7655/api/updates/status || true)
|
|
echo "Update status endpoint returned HTTP ${STATUS}"
|
|
case "${STATUS}" in
|
|
200|401|403)
|
|
;;
|
|
*)
|
|
echo "Unexpected response from /api/updates/status"
|
|
cat /tmp/update-status.json || true
|
|
exit 1
|
|
;;
|
|
esac
|
|
|
|
echo "Running multi-tenant E2E suite..."
|
|
npx playwright test tests/03-multi-tenant.spec.ts --project=chromium --reporter=list
|
|
|
|
docker compose -f docker-compose.test.yml down -v
|
|
|
|
- name: Collect integration diagnostics
|
|
if: failure()
|
|
working-directory: tests/integration
|
|
run: |
|
|
mkdir -p release-integration-diagnostics
|
|
{
|
|
echo "=== Docker containers ==="
|
|
docker ps -a || true
|
|
echo
|
|
echo "=== Pulse test server logs ==="
|
|
docker logs pulse-test-server 2>&1 || echo "No pulse-test-server container"
|
|
echo
|
|
echo "=== Mock GitHub server logs ==="
|
|
docker logs pulse-mock-github 2>&1 || echo "No pulse-mock-github container"
|
|
} | tee release-integration-diagnostics/docker.log
|
|
|
|
- name: Upload integration Playwright report
|
|
if: failure()
|
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
with:
|
|
name: release-integration-playwright-report
|
|
path: tests/integration/playwright-report/
|
|
if-no-files-found: ignore
|
|
retention-days: 14
|
|
|
|
- name: Upload integration failures
|
|
if: failure()
|
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
with:
|
|
name: release-integration-failures
|
|
path: |
|
|
tests/integration/test-results/
|
|
tests/integration/release-integration-diagnostics/
|
|
if-no-files-found: ignore
|
|
retention-days: 14
|
|
|
|
- name: Cleanup
|
|
if: always()
|
|
working-directory: tests/integration
|
|
run: docker compose -f docker-compose.test.yml down -v || true
|
|
|
|
# Create release after all checks pass
|
|
create_release:
|
|
needs:
|
|
- prepare
|
|
- build_release_candidate
|
|
- frontend_checks
|
|
- backend_tests
|
|
- docker_build
|
|
- helm_smoke
|
|
- integration_tests
|
|
# Run if integration_tests passed OR was skipped (prereleases)
|
|
if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' && always() && needs.build_release_candidate.result == 'success' && needs.frontend_checks.result == 'success' && needs.backend_tests.result == 'success' && needs.docker_build.result == 'success' && needs.helm_smoke.result == 'success' && (needs.integration_tests.result == 'success' || needs.integration_tests.result == 'skipped') }}
|
|
runs-on: ubuntu-24.04
|
|
timeout-minutes: 30
|
|
permissions:
|
|
contents: write
|
|
id-token: write
|
|
attestations: write
|
|
outputs:
|
|
release_id: ${{ steps.create_release.outputs.release_id }}
|
|
release_url: ${{ steps.create_release.outputs.release_url }}
|
|
target_commitish: ${{ github.sha }}
|
|
|
|
steps:
|
|
- name: Checkout repository
|
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
|
with:
|
|
fetch-depth: 0
|
|
|
|
- name: Download immutable release candidate
|
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
with:
|
|
name: ${{ needs.build_release_candidate.outputs.artifact_name }}
|
|
path: release
|
|
|
|
- name: Download release candidate manifest
|
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
with:
|
|
name: ${{ needs.build_release_candidate.outputs.manifest_artifact_name }}
|
|
path: release-candidate-manifest
|
|
|
|
- name: Verify immutable release candidate
|
|
run: |
|
|
python3 scripts/release_candidate_manifest.py verify-local \
|
|
--release-dir release \
|
|
--manifest release-candidate-manifest/release-candidate.json \
|
|
--version "${{ needs.prepare.outputs.version }}" \
|
|
--source-sha "${GITHUB_SHA}"
|
|
|
|
- name: Attest release assets
|
|
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
|
|
with:
|
|
subject-path: release/*
|
|
|
|
- name: Prepare release notes
|
|
id: generate_notes
|
|
run: |
|
|
VERSION="${{ needs.prepare.outputs.version }}"
|
|
RELEASE_NOTES_INPUT=$(jq -r '.inputs.release_notes // ""' "$GITHUB_EVENT_PATH" 2>/dev/null || echo "")
|
|
|
|
NOTES_FILE=$(mktemp)
|
|
|
|
if [ -n "$RELEASE_NOTES_INPUT" ]; then
|
|
printf "%s\n" "$RELEASE_NOTES_INPUT" > "$NOTES_FILE"
|
|
else
|
|
echo "# Pulse v${VERSION} Release Notes" > "$NOTES_FILE"
|
|
echo "" >> "$NOTES_FILE"
|
|
echo "See commit history for changes." >> "$NOTES_FILE"
|
|
fi
|
|
|
|
RENDERED_NOTES_FILE=$(mktemp)
|
|
python3 scripts/release_control/render_release_body.py \
|
|
--version "$VERSION" \
|
|
--release-notes-file "$NOTES_FILE" \
|
|
--output "$RENDERED_NOTES_FILE" \
|
|
--promotion-channel "${{ needs.prepare.outputs.is_prerelease == 'true' && 'rc' || 'stable' }}" \
|
|
--candidate-tag "${{ needs.prepare.outputs.tag }}" \
|
|
--promoted-prerelease-tag "${{ needs.prepare.outputs.promoted_from_tag }}" \
|
|
--rollback-target "${{ needs.prepare.outputs.rollback_tag }}" \
|
|
--rollback-command "${{ needs.prepare.outputs.rollback_command }}" \
|
|
--planned-ga-date "${{ needs.prepare.outputs.ga_date }}" \
|
|
--planned-v5-eos-date "${{ needs.prepare.outputs.v5_eos_date }}" \
|
|
--hotfix-exception "${{ needs.prepare.outputs.hotfix_exception }}" \
|
|
--hotfix-reason "${{ needs.prepare.outputs.hotfix_reason }}"
|
|
|
|
echo "notes_file=${RENDERED_NOTES_FILE}" >> $GITHUB_OUTPUT
|
|
|
|
- name: Locate existing release
|
|
id: existing_release
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
run: |
|
|
TAG="${{ needs.prepare.outputs.tag }}"
|
|
EXISTING_RELEASE=$(gh api "repos/${{ github.repository }}/releases?per_page=100" --paginate | jq -sc --arg tag "$TAG" 'add | map(select(.tag_name == $tag)) | first // empty')
|
|
RELEASE_ID=$(echo "$EXISTING_RELEASE" | jq -r '.id // empty')
|
|
RELEASE_URL=$(echo "$EXISTING_RELEASE" | jq -r '.html_url // empty')
|
|
RELEASE_IS_DRAFT=$(echo "$EXISTING_RELEASE" | jq -r '.draft // false')
|
|
RELEASE_PUBLISHED_AT=$(echo "$EXISTING_RELEASE" | jq -r '.published_at // empty')
|
|
|
|
echo "release_id=${RELEASE_ID}" >> $GITHUB_OUTPUT
|
|
echo "release_url=${RELEASE_URL}" >> $GITHUB_OUTPUT
|
|
echo "release_is_draft=${RELEASE_IS_DRAFT}" >> $GITHUB_OUTPUT
|
|
echo "release_published_at=${RELEASE_PUBLISHED_AT}" >> $GITHUB_OUTPUT
|
|
|
|
- name: Create tag
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
run: |
|
|
TAG="${{ needs.prepare.outputs.tag }}"
|
|
HEAD_SHA=$(git rev-parse HEAD)
|
|
EXISTING_RELEASE_ID="${{ steps.existing_release.outputs.release_id }}"
|
|
EXISTING_RELEASE_DRAFT="${{ steps.existing_release.outputs.release_is_draft }}"
|
|
EXISTING_RELEASE_PUBLISHED_AT="${{ steps.existing_release.outputs.release_published_at }}"
|
|
|
|
REMOTE_TAG_SHA=$(git ls-remote --tags origin "refs/tags/${TAG}" | awk '{print $1}')
|
|
|
|
if [ -n "$REMOTE_TAG_SHA" ]; then
|
|
REMOTE_COMMIT_SHA=$(git ls-remote --tags origin "refs/tags/${TAG}^{}" | awk '{print $1}')
|
|
[ -z "$REMOTE_COMMIT_SHA" ] && REMOTE_COMMIT_SHA="$REMOTE_TAG_SHA"
|
|
|
|
if [ "$REMOTE_COMMIT_SHA" = "$HEAD_SHA" ]; then
|
|
echo "Tag ${TAG} already exists and points to HEAD - continuing"
|
|
elif [ -n "$EXISTING_RELEASE_ID" ] && [ "$EXISTING_RELEASE_DRAFT" = "true" ] && [ -z "$EXISTING_RELEASE_PUBLISHED_AT" ]; then
|
|
echo "Retargeting existing draft tag ${TAG} from ${REMOTE_COMMIT_SHA} to ${HEAD_SHA}"
|
|
git config user.name "github-actions[bot]"
|
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
git tag -fa "${TAG}" -m "Release ${TAG}" "${HEAD_SHA}"
|
|
git push origin "refs/tags/${TAG}" --force
|
|
else
|
|
echo "::error::Tag ${TAG} already exists but points to ${REMOTE_COMMIT_SHA}, not HEAD (${HEAD_SHA}). Delete the tag first: git push origin --delete ${TAG}"
|
|
exit 1
|
|
fi
|
|
else
|
|
echo "Creating tag ${TAG}..."
|
|
git config user.name "github-actions[bot]"
|
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
git tag -a "${TAG}" -m "Release ${TAG}"
|
|
git push origin "${TAG}"
|
|
fi
|
|
|
|
- name: Create draft release
|
|
id: create_release
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
run: |
|
|
TAG="${{ needs.prepare.outputs.tag }}"
|
|
NOTES_FILE="${{ steps.generate_notes.outputs.notes_file }}"
|
|
IS_PRERELEASE="${{ needs.prepare.outputs.is_prerelease }}"
|
|
HEAD_SHA=$(git rev-parse HEAD)
|
|
|
|
RELEASE_ID="${{ steps.existing_release.outputs.release_id }}"
|
|
RELEASE_URL="${{ steps.existing_release.outputs.release_url }}"
|
|
IS_DRAFT="${{ steps.existing_release.outputs.release_is_draft }}"
|
|
PUBLISHED_AT="${{ steps.existing_release.outputs.release_published_at }}"
|
|
|
|
if [ -n "$RELEASE_ID" ]; then
|
|
if [ "$IS_DRAFT" = "true" ] && [ -z "$PUBLISHED_AT" ]; then
|
|
echo "Updating existing draft release for ${TAG}"
|
|
gh api "repos/${{ github.repository }}/releases/${RELEASE_ID}" \
|
|
-X PATCH \
|
|
-F tag_name="${TAG}" \
|
|
-F target_commitish="${HEAD_SHA}" \
|
|
-F name="Pulse ${TAG}" \
|
|
-F body="$(cat "$NOTES_FILE")" \
|
|
-F draft=true \
|
|
-F prerelease=${IS_PRERELEASE} > /dev/null
|
|
else
|
|
echo "::error::Published release already exists for ${TAG}."
|
|
exit 1
|
|
fi
|
|
else
|
|
echo "Creating draft release for ${TAG}..."
|
|
RELEASE_JSON=$(gh api "repos/${{ github.repository }}/releases" \
|
|
-X POST \
|
|
-F tag_name="${TAG}" \
|
|
-F target_commitish="${HEAD_SHA}" \
|
|
-F name="Pulse ${TAG}" \
|
|
-F body="$(cat "$NOTES_FILE")" \
|
|
-F draft=true \
|
|
-F prerelease=${IS_PRERELEASE})
|
|
|
|
RELEASE_ID=$(echo "$RELEASE_JSON" | jq -r '.id')
|
|
RELEASE_URL=$(echo "$RELEASE_JSON" | jq -r '.html_url')
|
|
fi
|
|
|
|
RELEASE_JSON=$(gh api "repos/${{ github.repository }}/releases/${RELEASE_ID}")
|
|
ACTUAL_RELEASE_TAG=$(echo "$RELEASE_JSON" | jq -r '.tag_name // empty')
|
|
ACTUAL_TARGET_COMMITISH=$(echo "$RELEASE_JSON" | jq -r '.target_commitish // empty')
|
|
RELEASE_URL=$(echo "$RELEASE_JSON" | jq -r '.html_url')
|
|
|
|
if [ "$ACTUAL_RELEASE_TAG" != "$TAG" ]; then
|
|
echo "::error::Draft release ${RELEASE_ID} is bound to tag ${ACTUAL_RELEASE_TAG}, expected ${TAG}."
|
|
exit 1
|
|
fi
|
|
|
|
if [ "$ACTUAL_TARGET_COMMITISH" != "$HEAD_SHA" ]; then
|
|
echo "::error::Draft release ${RELEASE_ID} target_commitish is ${ACTUAL_TARGET_COMMITISH}, expected ${HEAD_SHA}."
|
|
exit 1
|
|
fi
|
|
|
|
rm -f "$NOTES_FILE"
|
|
|
|
echo "release_url=${RELEASE_URL}" >> $GITHUB_OUTPUT
|
|
echo "release_id=${RELEASE_ID}" >> $GITHUB_OUTPUT
|
|
echo "[OK] Draft release: ${TAG} (ID: ${RELEASE_ID})"
|
|
|
|
- name: Upload checksums
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
run: |
|
|
TAG="${{ needs.prepare.outputs.tag }}"
|
|
release_upload_with_retry() {
|
|
local attempt=1
|
|
local max_attempts=5
|
|
local wait_seconds=15
|
|
|
|
while true; do
|
|
if gh release upload "$@"; then
|
|
return 0
|
|
fi
|
|
|
|
if [ "$attempt" -ge "$max_attempts" ]; then
|
|
echo "::error::gh release upload failed after ${max_attempts} attempts: $*"
|
|
return 1
|
|
fi
|
|
|
|
echo "gh release upload failed on attempt ${attempt}/${max_attempts}; retrying in ${wait_seconds}s: $*"
|
|
sleep "$wait_seconds"
|
|
attempt=$((attempt + 1))
|
|
if [ "$wait_seconds" -lt 120 ]; then
|
|
wait_seconds=$((wait_seconds * 2))
|
|
if [ "$wait_seconds" -gt 120 ]; then
|
|
wait_seconds=120
|
|
fi
|
|
fi
|
|
done
|
|
}
|
|
|
|
release_upload_with_retry "${TAG}" release/checksums.txt --clobber
|
|
release_upload_with_retry "${TAG}" release/*.sha256 --clobber
|
|
if ls release/*.sig 1> /dev/null 2>&1; then
|
|
release_upload_with_retry "${TAG}" release/*.sig --clobber
|
|
fi
|
|
if ls release/*.sshsig 1> /dev/null 2>&1; then
|
|
release_upload_with_retry "${TAG}" release/*.sshsig --clobber
|
|
fi
|
|
|
|
- name: Upload release assets
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
run: |
|
|
TAG="${{ needs.prepare.outputs.tag }}"
|
|
release_upload_with_retry() {
|
|
local attempt=1
|
|
local max_attempts=5
|
|
local wait_seconds=15
|
|
|
|
while true; do
|
|
if gh release upload "$@"; then
|
|
return 0
|
|
fi
|
|
|
|
if [ "$attempt" -ge "$max_attempts" ]; then
|
|
echo "::error::gh release upload failed after ${max_attempts} attempts: $*"
|
|
return 1
|
|
fi
|
|
|
|
echo "gh release upload failed on attempt ${attempt}/${max_attempts}; retrying in ${wait_seconds}s: $*"
|
|
sleep "$wait_seconds"
|
|
attempt=$((attempt + 1))
|
|
if [ "$wait_seconds" -lt 120 ]; then
|
|
wait_seconds=$((wait_seconds * 2))
|
|
if [ "$wait_seconds" -gt 120 ]; then
|
|
wait_seconds=120
|
|
fi
|
|
fi
|
|
done
|
|
}
|
|
|
|
if ls release/*.sbom.spdx.json 1> /dev/null 2>&1; then
|
|
release_upload_with_retry "${TAG}" release/*.sbom.spdx.json --clobber
|
|
fi
|
|
release_upload_with_retry "${TAG}" release/*.tar.gz --clobber
|
|
release_upload_with_retry "${TAG}" release/*.zip --clobber
|
|
if ls release/*.tgz 1> /dev/null 2>&1; then
|
|
release_upload_with_retry "${TAG}" release/*.tgz --clobber
|
|
fi
|
|
for bare_agent in \
|
|
release/pulse-agent-linux-amd64 \
|
|
release/pulse-agent-linux-arm64 \
|
|
release/pulse-agent-linux-armv7 \
|
|
release/pulse-agent-linux-armv6 \
|
|
release/pulse-agent-linux-386 \
|
|
release/pulse-agent-freebsd-amd64 \
|
|
release/pulse-agent-freebsd-arm64 \
|
|
release/pulse-agent-windows-amd64.exe \
|
|
release/pulse-agent-windows-arm64.exe \
|
|
release/pulse-agent-windows-386.exe; do
|
|
if [ -f "${bare_agent}" ]; then
|
|
release_upload_with_retry "${TAG}" "${bare_agent}" --clobber
|
|
fi
|
|
done
|
|
for bare_mcp in \
|
|
release/pulse-mcp-linux-amd64 \
|
|
release/pulse-mcp-linux-arm64 \
|
|
release/pulse-mcp-linux-armv7 \
|
|
release/pulse-mcp-linux-armv6 \
|
|
release/pulse-mcp-linux-386 \
|
|
release/pulse-mcp-darwin-amd64 \
|
|
release/pulse-mcp-darwin-arm64 \
|
|
release/pulse-mcp-freebsd-amd64 \
|
|
release/pulse-mcp-freebsd-arm64 \
|
|
release/pulse-mcp-windows-amd64.exe \
|
|
release/pulse-mcp-windows-arm64.exe \
|
|
release/pulse-mcp-windows-386.exe; do
|
|
if [ -f "${bare_mcp}" ]; then
|
|
release_upload_with_retry "${TAG}" "${bare_mcp}" --clobber
|
|
fi
|
|
done
|
|
release_upload_with_retry "${TAG}" release/install.sh --clobber
|
|
if [ -f release/install.ps1 ]; then
|
|
release_upload_with_retry "${TAG}" release/install.ps1 --clobber
|
|
fi
|
|
if [ -f release/install-mcp.sh ]; then
|
|
release_upload_with_retry "${TAG}" release/install-mcp.sh --clobber
|
|
fi
|
|
if [ -f release/install-mcp.ps1 ]; then
|
|
release_upload_with_retry "${TAG}" release/install-mcp.ps1 --clobber
|
|
fi
|
|
release_upload_with_retry "${TAG}" release/install-docker.sh --clobber
|
|
release_upload_with_retry "${TAG}" release/pulse-auto-update.sh --clobber
|
|
|
|
- name: Publish release
|
|
if: ${{ github.event.inputs.draft_only != 'true' }}
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
run: |
|
|
TAG="${{ needs.prepare.outputs.tag }}"
|
|
RELEASE_ID="${{ steps.create_release.outputs.release_id }}"
|
|
IS_PRERELEASE="${{ needs.prepare.outputs.is_prerelease }}"
|
|
|
|
if [ "$IS_PRERELEASE" = "true" ]; then
|
|
gh api "repos/${{ github.repository }}/releases/${RELEASE_ID}" \
|
|
-X PATCH -F draft=false -F make_latest=false
|
|
echo "[OK] Published as prerelease: ${TAG}"
|
|
else
|
|
# 'latest' belongs to the highest stable semver overall. A
|
|
# maintenance cut of an older line (e.g. v5.1.36 after v6 GA)
|
|
# publishes without stealing the latest marker from the current
|
|
# line.
|
|
HIGHEST_STABLE=$(gh api --paginate "repos/${{ github.repository }}/tags" --jq '.[].name' \
|
|
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1)
|
|
if [ "$TAG" = "$HIGHEST_STABLE" ]; then
|
|
gh api "repos/${{ github.repository }}/releases/${RELEASE_ID}" \
|
|
-X PATCH -F draft=false -F make_latest=true
|
|
echo "[OK] Published as latest: ${TAG}"
|
|
else
|
|
gh api "repos/${{ github.repository }}/releases/${RELEASE_ID}" \
|
|
-X PATCH -F draft=false -F make_latest=false
|
|
echo "[OK] Published WITHOUT latest marker: ${TAG} (highest stable is ${HIGHEST_STABLE})"
|
|
fi
|
|
fi
|
|
|
|
- name: Skip publish (draft only)
|
|
if: ${{ github.event.inputs.draft_only == 'true' }}
|
|
run: 'echo "Draft-only mode: ${{ steps.create_release.outputs.release_url }}"'
|
|
|
|
- name: Summary
|
|
run: |
|
|
echo "[SUCCESS] Release published!"
|
|
echo "Release: ${{ needs.prepare.outputs.tag }}"
|
|
echo "URL: ${{ steps.create_release.outputs.release_url }}"
|
|
|
|
backfill_release_assets:
|
|
needs:
|
|
- prepare
|
|
if: ${{ needs.prepare.outputs.historical_asset_backfill_only == 'true' }}
|
|
runs-on: ubuntu-24.04
|
|
timeout-minutes: 30
|
|
permissions:
|
|
contents: write
|
|
|
|
steps:
|
|
- name: Checkout repository
|
|
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: Install Syft
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
SYFT_VERSION="1.42.4"
|
|
SYFT_ARCHIVE="syft_${SYFT_VERSION}_linux_amd64.tar.gz"
|
|
SYFT_SHA256="590650c2743b83f327d1bf9bec64f6f83b7fec504187bb84f500c862bf8f2a0f"
|
|
TMP_DIR="$(mktemp -d)"
|
|
trap 'rm -rf "$TMP_DIR"' EXIT
|
|
|
|
curl -fsSL "https://github.com/anchore/syft/releases/download/v${SYFT_VERSION}/${SYFT_ARCHIVE}" \
|
|
-o "${TMP_DIR}/${SYFT_ARCHIVE}"
|
|
printf '%s %s\n' "${SYFT_SHA256}" "${TMP_DIR}/${SYFT_ARCHIVE}" | sha256sum --check --
|
|
tar -xzf "${TMP_DIR}/${SYFT_ARCHIVE}" -C "${TMP_DIR}" syft
|
|
install -m 0755 "${TMP_DIR}/syft" /usr/local/bin/syft
|
|
syft version
|
|
|
|
- name: Backfill published release assets
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
PULSE_UPDATE_SIGNING_KEY: ${{ secrets.PULSE_UPDATE_SIGNING_KEY }}
|
|
PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }}
|
|
run: |
|
|
./scripts/backfill-release-assets.sh --tag "${{ needs.prepare.outputs.tag }}" --repo "${{ github.repository }}"
|
|
|
|
- name: Validate published release packet
|
|
run: |
|
|
./scripts/validate-published-release.sh "${{ needs.prepare.outputs.tag }}" "${{ github.repository }}"
|
|
|
|
- name: Summary
|
|
run: |
|
|
echo "[SUCCESS] Historical release assets repaired"
|
|
echo "Release: ${{ needs.prepare.outputs.tag }}"
|
|
|
|
publish_docker:
|
|
needs:
|
|
- prepare
|
|
- create_release
|
|
if: ${{ always() && needs.prepare.result == 'success' && needs.create_release.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' && github.event.inputs.draft_only != 'true' }}
|
|
permissions:
|
|
contents: read
|
|
packages: write
|
|
id-token: write
|
|
attestations: write
|
|
uses: ./.github/workflows/publish-docker.yml
|
|
secrets: inherit
|
|
with:
|
|
tag: ${{ needs.prepare.outputs.tag }}
|
|
|
|
validate_release_assets:
|
|
needs:
|
|
- prepare
|
|
- build_release_candidate
|
|
- create_release
|
|
if: ${{ always() && needs.prepare.result == 'success' && needs.build_release_candidate.result == 'success' && needs.create_release.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' }}
|
|
permissions:
|
|
contents: write
|
|
issues: write
|
|
statuses: write
|
|
uses: ./.github/workflows/validate-release-assets.yml
|
|
secrets: inherit
|
|
with:
|
|
tag: ${{ needs.prepare.outputs.tag }}
|
|
version: ${{ needs.prepare.outputs.version }}
|
|
release_id: ${{ needs.create_release.outputs.release_id }}
|
|
draft: ${{ github.event.inputs.draft_only == 'true' }}
|
|
target_commitish: ${{ needs.create_release.outputs.target_commitish }}
|
|
candidate_manifest_artifact: ${{ needs.build_release_candidate.outputs.manifest_artifact_name }}
|
|
|
|
# End-to-end install.sh smoke against the just-published release. Catches
|
|
# runtime regressions in the documented Proxmox-LXC / systemd install flow
|
|
# that the build-time validate-release.sh checks cannot see: the script
|
|
# parses fine, signs cleanly, but fails to actually install or boot Pulse.
|
|
# This class of regression broke silently across v6 rc.1 → rc.5 because no
|
|
# existing gate exercised the documented secure-install commands against
|
|
# the published GitHub Release URL.
|
|
#
|
|
# Gated on validate_release_assets success — the smoke depends on the
|
|
# published asset bundle being well-formed, so we only run it after the
|
|
# cheaper content checks pass. Skipped for the historical-backfill path
|
|
# since that flow re-uploads to an already-published release and the
|
|
# smoke would just re-confirm what hasn't changed. Also skipped for
|
|
# draft-only runs because draft release assets are not available at the
|
|
# public /releases/download/<tag>/ URL this smoke intentionally exercises.
|
|
install_sh_smoke:
|
|
needs:
|
|
- prepare
|
|
- validate_release_assets
|
|
if: ${{ always() && needs.prepare.result == 'success' && needs.validate_release_assets.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' && github.event.inputs.draft_only != 'true' }}
|
|
permissions:
|
|
contents: read
|
|
uses: ./.github/workflows/install-sh-smoke.yml
|
|
secrets: inherit
|
|
with:
|
|
tag: ${{ needs.prepare.outputs.tag }}
|
|
version: ${{ needs.prepare.outputs.version }}
|
|
repository: ${{ github.repository }}
|
|
|
|
update_stable_demo:
|
|
needs:
|
|
- prepare
|
|
- validate_release_assets
|
|
if: ${{ always() && needs.prepare.result == 'success' && needs.validate_release_assets.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' && github.event.inputs.draft_only != 'true' && needs.prepare.outputs.is_prerelease != 'true' && startsWith(needs.prepare.outputs.version, '6.') }}
|
|
permissions:
|
|
contents: read
|
|
uses: ./.github/workflows/update-demo-server.yml
|
|
secrets: inherit
|
|
with:
|
|
tag: ${{ needs.prepare.outputs.tag }}
|
|
target: stable
|
|
verify_only: false
|
|
|
|
# Publish the Helm chart for this release. publish-helm-chart.yml also
|
|
# listens for `release: published` events directly, but the create_release
|
|
# publish step PATCHes a draft release to draft=false rather than creating
|
|
# it as draft=false from the start — that GitHub-documented path does NOT
|
|
# fire `release: published`. Across v6 rc.1 → rc.5 the release-event branch
|
|
# never triggered helm publish, leaving rcourtman.github.io/Pulse/index.yaml
|
|
# without any v6 chart and breaking `helm install pulse pulse/pulse
|
|
# --version 6.0.0-rc.5`. Calling the workflow explicitly here is the
|
|
# canonical fix. Draft-only runs must not publish the chart because the
|
|
# release has not crossed the operator-controlled publication boundary.
|
|
publish_helm_chart:
|
|
needs:
|
|
- prepare
|
|
- validate_release_assets
|
|
if: ${{ always() && needs.prepare.result == 'success' && needs.validate_release_assets.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' && github.event.inputs.draft_only != 'true' }}
|
|
permissions:
|
|
contents: write
|
|
packages: write
|
|
uses: ./.github/workflows/publish-helm-chart.yml
|
|
secrets: inherit
|
|
with:
|
|
chart_version: ${{ needs.prepare.outputs.version }}
|
|
app_version: ${{ needs.prepare.outputs.version }}
|
|
|
|
# Defensive backup to promote-floating-tags.yml's workflow_run chain off
|
|
# publish-docker.yml. The chain works when publish-docker succeeds, but
|
|
# when it fails the floating tags don't advance and customers pulling
|
|
# rcourtman/pulse:latest stay on whatever the previous successful release
|
|
# tagged. Calling promote-floating-tags as workflow_call after
|
|
# validate_release_assets and publish_docker succeed guarantees the floating
|
|
# tags advance. Draft-only runs must not promote floating tags because the
|
|
# release is still in private promotion state.
|
|
promote_floating_tags:
|
|
needs:
|
|
- prepare
|
|
- publish_docker
|
|
- validate_release_assets
|
|
if: ${{ always() && needs.prepare.result == 'success' && needs.publish_docker.result == 'success' && needs.validate_release_assets.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' && github.event.inputs.draft_only != 'true' }}
|
|
permissions:
|
|
contents: read
|
|
packages: write
|
|
uses: ./.github/workflows/promote-floating-tags.yml
|
|
secrets: inherit
|
|
with:
|
|
tag: ${{ needs.prepare.outputs.tag }}
|
|
prerelease: ${{ needs.prepare.outputs.is_prerelease == 'true' }}
|
|
|
|
# Customer-facing v6 public releases must not outrun the private Pulse Pro
|
|
# runtime path. The public release is the immutable source tag; this job
|
|
# dispatches the private build against that exact tag, waits for the R2 and
|
|
# Docker publication workflow to pass, then dispatches the pulse-pro live
|
|
# promotion workflow and waits for the signed packet to update the license
|
|
# broker. A failure here fails the release pipeline instead of leaving paid
|
|
# customers on a stale private manifest.
|
|
publish_private_pro_runtime:
|
|
needs:
|
|
- prepare
|
|
- create_release
|
|
if: ${{ always() && needs.prepare.result == 'success' && needs.create_release.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' && github.event.inputs.draft_only != 'true' && startsWith(needs.prepare.outputs.version, '6.') }}
|
|
runs-on: ubuntu-24.04
|
|
timeout-minutes: 150
|
|
steps:
|
|
- name: Dispatch and verify private Pro runtime publication
|
|
env:
|
|
GH_TOKEN: ${{ secrets.WORKFLOW_PAT }}
|
|
VERSION: ${{ needs.prepare.outputs.version }}
|
|
TAG: ${{ needs.prepare.outputs.tag }}
|
|
IS_PRERELEASE: ${{ needs.prepare.outputs.is_prerelease }}
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
if [[ -z "${GH_TOKEN:-}" ]]; then
|
|
echo "::error::WORKFLOW_PAT is required to dispatch private Pro publication workflows."
|
|
exit 1
|
|
fi
|
|
|
|
wait_for_workflow() {
|
|
local repo="$1"
|
|
local workflow="$2"
|
|
local branch="$3"
|
|
local started_at="$4"
|
|
local label="$5"
|
|
local timeout_seconds="$6"
|
|
local deadline=$((SECONDS + timeout_seconds))
|
|
local run_id=""
|
|
|
|
while (( SECONDS < deadline )); do
|
|
if [[ -z "${run_id}" ]]; then
|
|
run_id="$(
|
|
gh run list \
|
|
--repo "${repo}" \
|
|
--workflow "${workflow}" \
|
|
--event workflow_dispatch \
|
|
--branch "${branch}" \
|
|
--limit 50 \
|
|
--json databaseId,createdAt \
|
|
--jq "map(select(.createdAt >= \"${started_at}\")) | sort_by(.createdAt) | reverse | .[0].databaseId // \"\""
|
|
)"
|
|
if [[ -n "${run_id}" ]]; then
|
|
echo "Watching ${label} run ${run_id} in ${repo}."
|
|
else
|
|
echo "Waiting for ${label} workflow run to appear..."
|
|
fi
|
|
fi
|
|
|
|
if [[ -n "${run_id}" ]]; then
|
|
run_state="$(
|
|
gh run view "${run_id}" \
|
|
--repo "${repo}" \
|
|
--json status,conclusion,url \
|
|
--jq '[.status, (.conclusion // ""), .url] | @tsv'
|
|
)"
|
|
status="$(awk -F '\t' '{print $1}' <<<"${run_state}")"
|
|
conclusion="$(awk -F '\t' '{print $2}' <<<"${run_state}")"
|
|
url="$(awk -F '\t' '{print $3}' <<<"${run_state}")"
|
|
echo "${label}: status=${status} conclusion=${conclusion:-pending} ${url}"
|
|
if [[ "${status}" == "completed" ]]; then
|
|
if [[ "${conclusion}" == "success" ]]; then
|
|
echo "[OK] ${label} completed successfully: ${url}"
|
|
return 0
|
|
fi
|
|
echo "::error::${label} failed with conclusion=${conclusion}: ${url}"
|
|
return 1
|
|
fi
|
|
fi
|
|
|
|
sleep 30
|
|
done
|
|
|
|
echo "::error::Timed out waiting for ${label} after ${timeout_seconds}s."
|
|
return 1
|
|
}
|
|
|
|
allow_ga_publish=false
|
|
if [[ "${IS_PRERELEASE}" != "true" ]]; then
|
|
allow_ga_publish=true
|
|
fi
|
|
|
|
r2_prefix="${TAG}-pro-$(date -u '+%Y%m%d')-${GITHUB_RUN_ID}"
|
|
build_started_at="$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
|
|
echo "Dispatching private Pro build for ${TAG} with R2 prefix ${r2_prefix}."
|
|
gh workflow run build-pro-release.yml \
|
|
--repo rcourtman/pulse-enterprise \
|
|
--ref main \
|
|
-f pulse_ref="${TAG}" \
|
|
-f version="${VERSION}" \
|
|
-f upload_actions_artifact=false \
|
|
-f upload_to_r2=true \
|
|
-f publish_docker_image=true \
|
|
-f docker_image=license.pulserelay.pro/pulse-pro \
|
|
-f r2_prefix="${r2_prefix}" \
|
|
-f allow_stable_ga_publish="${allow_ga_publish}"
|
|
wait_for_workflow rcourtman/pulse-enterprise "Build Pro Release" main "${build_started_at}" "private Pro build" 7200
|
|
|
|
promote_started_at="$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
|
|
echo "Dispatching live paid-runtime promotion for ${TAG} with R2 prefix ${r2_prefix}."
|
|
gh workflow run promote-paid-runtime-release.yml \
|
|
--repo rcourtman/pulse-pro \
|
|
--ref main \
|
|
-f version="${VERSION}" \
|
|
-f r2_prefix="${r2_prefix}" \
|
|
-f allow_ga_prefix="${allow_ga_publish}"
|
|
wait_for_workflow rcourtman/pulse-pro "Promote Paid Runtime Release" main "${promote_started_at}" "private Pro live promotion" 3600
|
|
|
|
release_verdict:
|
|
name: Definitive Release Verdict
|
|
needs:
|
|
- prepare
|
|
- create_release
|
|
- publish_docker
|
|
- validate_release_assets
|
|
- install_sh_smoke
|
|
- update_stable_demo
|
|
- publish_helm_chart
|
|
- promote_floating_tags
|
|
- publish_private_pro_runtime
|
|
if: ${{ always() && needs.prepare.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' }}
|
|
runs-on: ubuntu-24.04
|
|
steps:
|
|
- name: Enforce terminal release outcomes
|
|
env:
|
|
DRAFT_ONLY: ${{ github.event.inputs.draft_only }}
|
|
VERSION: ${{ needs.prepare.outputs.version }}
|
|
IS_PRERELEASE: ${{ needs.prepare.outputs.is_prerelease }}
|
|
CREATE_RESULT: ${{ needs.create_release.result }}
|
|
DOCKER_RESULT: ${{ needs.publish_docker.result }}
|
|
VALIDATE_RESULT: ${{ needs.validate_release_assets.result }}
|
|
INSTALL_RESULT: ${{ needs.install_sh_smoke.result }}
|
|
DEMO_RESULT: ${{ needs.update_stable_demo.result }}
|
|
HELM_RESULT: ${{ needs.publish_helm_chart.result }}
|
|
FLOATING_RESULT: ${{ needs.promote_floating_tags.result }}
|
|
PRIVATE_PRO_RESULT: ${{ needs.publish_private_pro_runtime.result }}
|
|
run: |
|
|
set -euo pipefail
|
|
require_result() {
|
|
local name="$1"
|
|
local actual="$2"
|
|
local expected="$3"
|
|
if [ "$actual" != "$expected" ]; then
|
|
echo "::error::${name} ended as ${actual}; expected ${expected}."
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
require_result "release assembly" "$CREATE_RESULT" success
|
|
require_result "release asset validation" "$VALIDATE_RESULT" success
|
|
|
|
if [ "${DRAFT_ONLY:-false}" != "true" ]; then
|
|
require_result "Docker publication" "$DOCKER_RESULT" success
|
|
require_result "install.sh smoke" "$INSTALL_RESULT" success
|
|
require_result "Helm publication" "$HELM_RESULT" success
|
|
require_result "floating-tag promotion" "$FLOATING_RESULT" success
|
|
if [[ "$VERSION" == 6.* ]]; then
|
|
require_result "private Pro publication" "$PRIVATE_PRO_RESULT" success
|
|
if [ "$IS_PRERELEASE" != "true" ]; then
|
|
require_result "stable demo deployment and verification" "$DEMO_RESULT" success
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
echo "Release verdict passed for v${VERSION}."
|