mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-21 18:53:37 +00:00
bd6f77e093
Tighten v5-to-v6 upgrade safety, release installability, provider MSP mode handling, AI cost accounting, metrics flushing, and frontend guardrails for the v6.0.0 GA candidate.
1141 lines
47 KiB
YAML
1141 lines
47 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) - generated by Claude'
|
|
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
|
|
|
|
concurrency:
|
|
group: release-${{ github.event.inputs.version || github.ref || github.run_id }}
|
|
cancel-in-progress: false
|
|
|
|
permissions:
|
|
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 }}
|
|
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@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
|
with:
|
|
fetch-depth: 0
|
|
sparse-checkout: |
|
|
VERSION
|
|
docs/release-control/control_plane.json
|
|
scripts/release_control/control_plane.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 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}"
|
|
|
|
# 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@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
|
|
|
- name: Set up Node.js
|
|
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
|
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
|
|
|
|
# Backend tests run in parallel with frontend checks
|
|
backend_tests:
|
|
needs: prepare
|
|
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@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
|
|
|
- name: Set up Node.js
|
|
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
|
with:
|
|
node-version: '20'
|
|
cache: 'npm'
|
|
cache-dependency-path: 'frontend-modern/package-lock.json'
|
|
|
|
- name: Restore frontend build cache
|
|
id: frontend-cache
|
|
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
|
|
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 (if not cached)
|
|
if: steps.frontend-cache.outputs.cache-hit != 'true'
|
|
run: |
|
|
npm --prefix frontend-modern ci
|
|
npm --prefix frontend-modern run build
|
|
|
|
- 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@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
|
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@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
|
|
|
- name: Set up QEMU
|
|
if: needs.prepare.outputs.is_prerelease != 'true'
|
|
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3
|
|
|
|
- name: Set up Docker Buildx
|
|
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
|
|
|
- name: Log in to GHCR
|
|
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
|
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@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
|
|
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@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
|
|
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@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
|
|
|
- name: Set up Helm
|
|
uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4
|
|
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
|
|
- backend_tests
|
|
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@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
|
|
|
- name: Set up Node.js
|
|
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
|
with:
|
|
node-version: '20'
|
|
cache: 'npm'
|
|
cache-dependency-path: 'frontend-modern/package-lock.json'
|
|
|
|
- name: Restore frontend build cache
|
|
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
|
|
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: 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@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
|
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 "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: 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
|
|
- 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.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@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
|
with:
|
|
fetch-depth: 0
|
|
|
|
- name: Set up Go
|
|
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
|
with:
|
|
go-version-file: go.mod
|
|
cache: true
|
|
|
|
- name: Set up Node.js
|
|
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
|
with:
|
|
node-version: '20'
|
|
cache: 'npm'
|
|
cache-dependency-path: 'frontend-modern/package-lock.json'
|
|
|
|
- name: Install dependencies
|
|
run: |
|
|
sudo apt-get update
|
|
sudo apt-get install -y zip
|
|
|
|
- name: Set up Helm
|
|
uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4
|
|
with:
|
|
version: 'v3.15.2'
|
|
|
|
- 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: Build release artifacts
|
|
run: |
|
|
echo "Building release ${{ needs.prepare.outputs.tag }}..."
|
|
./scripts/build-release.sh ${{ needs.prepare.outputs.version }}
|
|
env:
|
|
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 }}
|
|
|
|
- name: Validate installer signing key pins
|
|
env:
|
|
PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }}
|
|
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
|
|
)"
|
|
|
|
for installer in install.sh scripts/pulse-auto-update.sh release/pulse-auto-update.sh; do
|
|
grep -F "PINNED_RELEASE_SSH_PUBLIC_KEY=\"${TRUSTED_SSH_PUBLIC_KEY}\"" "${installer}" >/dev/null || {
|
|
echo "::error::${installer} does not trust the configured release signing key."
|
|
exit 1
|
|
}
|
|
done
|
|
|
|
- name: Post-build health check
|
|
run: |
|
|
if [ -x ./pulse ]; then
|
|
./pulse --version
|
|
elif [ -x ./cmd/pulse/pulse ]; then
|
|
./cmd/pulse/pulse --version
|
|
fi
|
|
|
|
- name: Attest release assets
|
|
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4
|
|
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
|
|
gh api "repos/${{ github.repository }}/releases/${RELEASE_ID}" \
|
|
-X PATCH -F draft=false -F make_latest=true
|
|
echo "[OK] Published as latest: ${TAG}"
|
|
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: Trigger Docker image publish
|
|
if: ${{ github.event.inputs.draft_only != 'true' }}
|
|
continue-on-error: true
|
|
env:
|
|
GH_TOKEN: ${{ secrets.WORKFLOW_PAT }}
|
|
REQUIRED_BRANCH: ${{ needs.prepare.outputs.required_branch }}
|
|
run: |
|
|
gh workflow run publish-docker.yml --ref "${REQUIRED_BRANCH}" -f tag="${{ needs.prepare.outputs.tag }}"
|
|
echo "[OK] Docker publish workflow dispatched from ${REQUIRED_BRANCH}"
|
|
|
|
- name: Trigger demo server update
|
|
if: ${{ github.event.inputs.draft_only != 'true' }}
|
|
continue-on-error: true
|
|
env:
|
|
GH_TOKEN: ${{ secrets.WORKFLOW_PAT }}
|
|
REQUIRED_BRANCH: ${{ needs.prepare.outputs.required_branch }}
|
|
run: |
|
|
if [ "${{ needs.prepare.outputs.is_prerelease }}" = "true" ]; then
|
|
TARGET="preview-v6"
|
|
else
|
|
TARGET="stable"
|
|
fi
|
|
gh workflow run update-demo-server.yml --ref "${REQUIRED_BRANCH}" -f tag="${{ needs.prepare.outputs.tag }}" -f target="${TARGET}"
|
|
echo "[OK] Demo server update dispatched for ${TARGET} from ${REQUIRED_BRANCH}"
|
|
|
|
- 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@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
|
|
|
- name: Set up Go
|
|
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
|
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 }}"
|
|
|
|
validate_release_assets:
|
|
needs:
|
|
- prepare
|
|
- create_release
|
|
if: ${{ always() && needs.prepare.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 }}
|
|
|
|
# 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 }}
|
|
|
|
# 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 succeeds (which itself waits for the docker
|
|
# image to be pullable) 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
|
|
- 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
|
|
packages: write
|
|
uses: ./.github/workflows/promote-floating-tags.yml
|
|
secrets: inherit
|
|
with:
|
|
tag: ${{ needs.prepare.outputs.tag }}
|
|
prerelease: ${{ needs.prepare.outputs.is_prerelease == 'true' }}
|