mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-09 18:15:50 +00:00
ae5df19b62
The internal/api race suite now routinely exceeds the old 20-minute package timeout on hosted runners while passing. Set a governed 30-minute package timeout and 40-minute release job ceiling, pin the relationship with contract tests, and refresh the rc.4 packet with the fixes landed since preparation.
1826 lines
80 KiB
YAML
1826 lines
80 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
|
|
unsigned_windows_exception:
|
|
description: 'v6.1.0, v6.1.1, v6.1.2, v6.2.0, or v6.2.1 only: publish Windows agents without Authenticode under a recorded owner exception'
|
|
required: false
|
|
type: boolean
|
|
default: false
|
|
unsigned_windows_reason:
|
|
description: 'v6.1.0, v6.1.1, v6.1.2, v6.2.0, or v6.2.1 only: owner reason for the unsigned Windows 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-v${{ 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 }}
|
|
require_windows_signing: ${{ steps.promotion.outputs.require_windows_signing }}
|
|
unsigned_windows_exception: ${{ steps.promotion.outputs.unsigned_windows_exception }}
|
|
unsigned_windows_reason: ${{ steps.promotion.outputs.unsigned_windows_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 }}
|
|
UNSIGNED_WINDOWS_EXCEPTION_INPUT: ${{ github.event.inputs.unsigned_windows_exception }}
|
|
UNSIGNED_WINDOWS_REASON_INPUT: ${{ github.event.inputs.unsigned_windows_reason }}
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
git fetch --prune origin main "${REQUIRED_BRANCH}" --tags
|
|
|
|
NOTES_FILE="$(mktemp)"
|
|
if ! jq -er '.inputs.release_notes | select(type == "string" and length > 0)' \
|
|
"$GITHUB_EVENT_PATH" > "$NOTES_FILE"; then
|
|
echo "::error::release_notes must be a non-empty Markdown string"
|
|
exit 1
|
|
fi
|
|
|
|
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
|
|
if [ "${UNSIGNED_WINDOWS_EXCEPTION_INPUT:-false}" = "true" ]; then
|
|
HELPER_ARGS+=(
|
|
--unsigned-windows-exception
|
|
--unsigned-windows-reason "${UNSIGNED_WINDOWS_REASON_INPUT:-}"
|
|
)
|
|
elif [ -n "${UNSIGNED_WINDOWS_REASON_INPUT:-}" ]; then
|
|
HELPER_ARGS+=(--unsigned-windows-reason "${UNSIGNED_WINDOWS_REASON_INPUT}")
|
|
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:
|
|
actions: read
|
|
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.require_windows_signing == 'true' }}
|
|
windows_signing_backend: signpath
|
|
|
|
# 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: 20
|
|
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: Type-check frontend
|
|
run: npm --prefix frontend-modern run type-check
|
|
|
|
- name: Test frontend
|
|
run: npm --prefix frontend-modern test
|
|
|
|
- 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
|
|
|
|
windows_install_command_smoke:
|
|
name: Windows PowerShell 5.1 Install Command Smoke
|
|
needs: prepare
|
|
if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' }}
|
|
runs-on: windows-2025
|
|
timeout-minutes: 15
|
|
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 frontend test dependencies
|
|
working-directory: frontend-modern
|
|
run: npm ci
|
|
|
|
- name: Execute generated command with Windows PowerShell 5.1
|
|
working-directory: frontend-modern
|
|
run: npm test -- --run src/utils/__tests__/agentInstallCommand.windows.test.ts
|
|
|
|
# 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
|
|
# The race-enabled internal/api package has measured at 24 minutes on the
|
|
# hosted runner. Keep the job ceiling above the Makefile's 30-minute
|
|
# per-package timeout so setup and result collection cannot become the
|
|
# effective release gate.
|
|
timeout-minutes: 40
|
|
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 under the published pulse package, not a pulse-agent package.
|
|
# No workflow pushes a pulse-agent image, so a buildcache ref there
|
|
# resurrects an empty package in the repo's Packages sidebar that
|
|
# reads like a pullable agent image. Blobs are also shared with the
|
|
# runtime build, which uses the same backend-builder layers.
|
|
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 current organization-sharing E2E suite..."
|
|
npx playwright test \
|
|
tests/66-organization-sharing-approval-ui.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
|
|
# Release smoke: render-level assertions on the primary surfaces (Proxmox,
|
|
# Docker, Kubernetes, Alert thresholds), run for EVERY cut including
|
|
# prereleases. integration_tests stays stable-only for depth; this job
|
|
# exists because v6.2.0-rc.5 shipped with its primary surfaces broken while
|
|
# the only coverage lived in non-gating CI tiers (#1663). A prerelease is
|
|
# the build users test — it must never skip the "do the pages render data"
|
|
# bar.
|
|
release_smoke:
|
|
needs:
|
|
- prepare
|
|
- frontend_checks
|
|
if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' }}
|
|
runs-on: ubuntu-24.04
|
|
timeout-minutes: 30
|
|
env:
|
|
PULSE_E2E_BOOTSTRAP_TOKEN: 0123456789abcdef0123456789abcdef0123456789abcdef
|
|
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: Build Docker image for the smoke environment
|
|
# GO_BUILD_TAGS="" drops the release build tag so mock fixtures are
|
|
# available; the release-tagged binary itself is covered by
|
|
# backend_tests and build_release_candidate.
|
|
run: |
|
|
docker build -t pulse:test --target e2e_runtime --build-arg GO_BUILD_TAGS="" .
|
|
docker build -t pulse-mock-github:test tests/integration/mock-github-server
|
|
env:
|
|
PULSE_LICENSE_PUBLIC_KEY: ${{ secrets.PULSE_LICENSE_PUBLIC_KEY }}
|
|
|
|
- name: Install Playwright
|
|
working-directory: tests/integration
|
|
run: |
|
|
npm ci
|
|
npx playwright install --with-deps chromium
|
|
|
|
- name: Run release smoke
|
|
working-directory: tests/integration
|
|
env:
|
|
MOCK_CHECKSUM_ERROR: "false"
|
|
MOCK_NETWORK_ERROR: "false"
|
|
MOCK_RATE_LIMIT: "false"
|
|
MOCK_STALE_RELEASE: "false"
|
|
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
|
|
|
|
npx playwright test tests/95-release-smoke.spec.ts \
|
|
--project=chromium \
|
|
--reporter=list
|
|
|
|
docker compose -f docker-compose.test.yml down -v
|
|
|
|
- name: Collect smoke diagnostics
|
|
if: failure()
|
|
working-directory: tests/integration
|
|
run: |
|
|
docker logs pulse-test-server || true
|
|
docker compose -f docker-compose.test.yml down -v || true
|
|
|
|
- name: Upload smoke diagnostics
|
|
if: failure()
|
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
with:
|
|
name: release-smoke-failures-${{ github.sha }}
|
|
path: |
|
|
tests/integration/test-results/
|
|
tests/integration/playwright-report/
|
|
if-no-files-found: ignore
|
|
retention-days: 14
|
|
|
|
create_release:
|
|
needs:
|
|
- prepare
|
|
- build_release_candidate
|
|
- frontend_checks
|
|
- windows_install_command_smoke
|
|
- backend_tests
|
|
- docker_build
|
|
- helm_smoke
|
|
- integration_tests
|
|
- release_smoke
|
|
# Run if integration_tests passed OR was skipped (prereleases). The
|
|
# release smoke has no skipped escape: it runs for prereleases too.
|
|
if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' && always() && needs.build_release_candidate.result == 'success' && needs.frontend_checks.result == 'success' && needs.windows_install_command_smoke.result == 'success' && needs.backend_tests.result == 'success' && needs.docker_build.result == 'success' && needs.helm_smoke.result == 'success' && needs.release_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: |
|
|
set -euo pipefail
|
|
VERSION="${{ needs.prepare.outputs.version }}"
|
|
NOTES_FILE=$(mktemp)
|
|
if ! jq -er '.inputs.release_notes | select(type == "string" and length > 0)' \
|
|
"$GITHUB_EVENT_PATH" > "$NOTES_FILE"; then
|
|
echo "::error::release_notes must be a non-empty Markdown string"
|
|
exit 1
|
|
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 }}" \
|
|
--require-windows-signing "${{ needs.prepare.outputs.require_windows_signing }}" \
|
|
--unsigned-windows-exception "${{ needs.prepare.outputs.unsigned_windows_exception }}" \
|
|
--unsigned-windows-reason "${{ needs.prepare.outputs.unsigned_windows_reason }}"
|
|
|
|
# Highlights provide the compact pre-update preview. The post-update
|
|
# dialog uses categorized changelog entries instead of that summary.
|
|
if grep -qiE '^#{1,6}[[:space:]]+highlights\b' "$RENDERED_NOTES_FILE"; then
|
|
echo "::notice::Release notes include Highlights — the update banner can preview them before users update."
|
|
else
|
|
echo "::notice::Release notes have no Highlights — the update banner will not show a summary preview."
|
|
fi
|
|
|
|
if grep -qiE '^#{1,6}[[:space:]]+(added|new features|improved|improvements|changed|fixed|bug fixes|security|breaking changes|deprecated|removed)[[:space:]]*$' "$RENDERED_NOTES_FILE"; then
|
|
echo "::notice::Release notes include categorized changes — the post-update changelog dialog will show them."
|
|
else
|
|
echo "::notice::Release notes have no categorized changes — the post-update changelog dialog stays silent."
|
|
fi
|
|
|
|
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')
|
|
RELEASE_ACTIVATION_COMMITTED=$(echo "$EXISTING_RELEASE" | jq -r 'any(.assets[]?; .name == "release-activation.json")')
|
|
|
|
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
|
|
echo "release_activation_committed=${RELEASE_ACTIVATION_COMMITTED}" >> $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 }}"
|
|
EXISTING_RELEASE_ACTIVATION_COMMITTED="${{ steps.existing_release.outputs.release_activation_committed }}"
|
|
|
|
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" ] && [ "$EXISTING_RELEASE_ACTIVATION_COMMITTED" != "true" ]; then
|
|
if [ -n "$EXISTING_RELEASE_PUBLISHED_AT" ]; then
|
|
echo "Resuming quarantined draft for ${TAG}; GitHub retained historical published_at=${EXISTING_RELEASE_PUBLISHED_AT}."
|
|
fi
|
|
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: |
|
|
set -euo pipefail
|
|
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 }}"
|
|
ACTIVATION_COMMITTED="${{ steps.existing_release.outputs.release_activation_committed }}"
|
|
RELEASE_PAYLOAD=$(mktemp)
|
|
RELEASE_JSON_FILE=$(mktemp)
|
|
ACTUAL_BODY_FILE=$(mktemp)
|
|
|
|
jq -n \
|
|
--arg tag_name "$TAG" \
|
|
--arg target_commitish "$HEAD_SHA" \
|
|
--arg name "Pulse ${TAG}" \
|
|
--rawfile body "$NOTES_FILE" \
|
|
--argjson draft true \
|
|
--argjson prerelease "$IS_PRERELEASE" \
|
|
'{
|
|
tag_name: $tag_name,
|
|
target_commitish: $target_commitish,
|
|
name: $name,
|
|
body: $body,
|
|
draft: $draft,
|
|
prerelease: $prerelease
|
|
}' > "$RELEASE_PAYLOAD"
|
|
|
|
if [ -n "$RELEASE_ID" ]; then
|
|
if [ "$IS_DRAFT" = "true" ] && [ "$ACTIVATION_COMMITTED" != "true" ]; then
|
|
if [ -n "$PUBLISHED_AT" ]; then
|
|
echo "Resuming quarantined draft release for ${TAG}; GitHub retained historical published_at=${PUBLISHED_AT}."
|
|
fi
|
|
echo "Updating existing draft release for ${TAG}"
|
|
gh api "repos/${{ github.repository }}/releases/${RELEASE_ID}" \
|
|
-X PATCH \
|
|
--input "$RELEASE_PAYLOAD" > "$RELEASE_JSON_FILE"
|
|
else
|
|
echo "::error::Published release already exists for ${TAG}."
|
|
exit 1
|
|
fi
|
|
else
|
|
echo "Creating draft release for ${TAG}..."
|
|
gh api "repos/${{ github.repository }}/releases" \
|
|
-X POST \
|
|
--input "$RELEASE_PAYLOAD" > "$RELEASE_JSON_FILE"
|
|
|
|
RELEASE_ID=$(jq -r '.id' "$RELEASE_JSON_FILE")
|
|
RELEASE_URL=$(jq -r '.html_url' "$RELEASE_JSON_FILE")
|
|
fi
|
|
|
|
gh api "repos/${{ github.repository }}/releases/${RELEASE_ID}" > "$RELEASE_JSON_FILE"
|
|
ACTUAL_RELEASE_TAG=$(jq -r '.tag_name // empty' "$RELEASE_JSON_FILE")
|
|
ACTUAL_TARGET_COMMITISH=$(jq -r '.target_commitish // empty' "$RELEASE_JSON_FILE")
|
|
RELEASE_URL=$(jq -r '.html_url' "$RELEASE_JSON_FILE")
|
|
jq -r '.body // ""' "$RELEASE_JSON_FILE" > "$ACTUAL_BODY_FILE"
|
|
|
|
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
|
|
|
|
python3 scripts/release_control/render_release_body.py \
|
|
--version "${{ needs.prepare.outputs.version }}" \
|
|
--validate-body-file "$ACTUAL_BODY_FILE" \
|
|
--expected-body-file "$NOTES_FILE"
|
|
|
|
rm -f "$NOTES_FILE" "$RELEASE_PAYLOAD" "$RELEASE_JSON_FILE" "$ACTUAL_BODY_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: Stop after staging (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 assets staged behind an unpublished draft."
|
|
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: 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 staged draft 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 exact GitHub Release asset bytes before the customer notification.
|
|
#
|
|
# Gated on validate_release_assets success — the smoke depends on the
|
|
# staged 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. Draft-only runs stop after
|
|
# validation and do not enter the customer activation sequence.
|
|
install_sh_smoke:
|
|
needs:
|
|
- prepare
|
|
- create_release
|
|
- validate_release_assets
|
|
if: ${{ always() && needs.prepare.result == 'success' && needs.create_release.result == 'success' && needs.validate_release_assets.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' && github.event.inputs.draft_only != 'true' }}
|
|
permissions:
|
|
# GitHub's release API requires write-level repository access to read
|
|
# assets from an unpublished draft release. The called workflow only
|
|
# performs GET requests, but a read-scoped GITHUB_TOKEN receives 403.
|
|
contents: write
|
|
uses: ./.github/workflows/install-sh-smoke.yml
|
|
secrets: inherit
|
|
with:
|
|
tag: ${{ needs.prepare.outputs.tag }}
|
|
version: ${{ needs.prepare.outputs.version }}
|
|
repository: ${{ github.repository }}
|
|
asset_source: staged
|
|
release_id: ${{ needs.create_release.outputs.release_id }}
|
|
|
|
# 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 }}
|
|
|
|
# One immutable-readiness gate joins every exact-version path before the
|
|
# GitHub release crosses its public activation boundary. v6 additionally
|
|
# requires the staged Pro image and signed packet; older release lines have
|
|
# no private Pro job. Mutable indexes, aliases, brokers, and live environments
|
|
# are deliberately excluded from this pre-activation join.
|
|
release_readiness:
|
|
needs:
|
|
- prepare
|
|
- create_release
|
|
- publish_docker
|
|
- validate_release_assets
|
|
- install_sh_smoke
|
|
- publish_helm_chart
|
|
- stage_private_pro_runtime
|
|
if: ${{ always() && needs.prepare.result == 'success' && needs.create_release.result == 'success' && needs.publish_docker.result == 'success' && needs.validate_release_assets.result == 'success' && needs.install_sh_smoke.result == 'success' && needs.publish_helm_chart.result == 'success' && ( !startsWith(needs.prepare.outputs.version, '6.') || needs.stage_private_pro_runtime.result == 'success' ) && needs.prepare.outputs.historical_asset_backfill_only != 'true' && github.event.inputs.draft_only != 'true' }}
|
|
runs-on: ubuntu-24.04
|
|
timeout-minutes: 5
|
|
steps:
|
|
- name: Confirm immutable release readiness
|
|
run: echo "All exact-version release paths are ready for customer activation."
|
|
|
|
# Stage the exact private Pro image and signed R2 packet as soon as the draft
|
|
# release/tag exist. This slow build runs in parallel with public artifact
|
|
# validation, but does not update the live paid-runtime broker manifest.
|
|
stage_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: 120
|
|
outputs:
|
|
r2_prefix: ${{ steps.publish.outputs.r2_prefix }}
|
|
steps:
|
|
- name: Dispatch and verify private Pro runtime staging
|
|
id: publish
|
|
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 run_id="$2"
|
|
local label="$3"
|
|
local timeout_seconds="$4"
|
|
local deadline=$((SECONDS + timeout_seconds))
|
|
|
|
if [[ ! "${run_id}" =~ ^[0-9]+$ ]]; then
|
|
echo "::error::Dispatch for ${label} did not return an exact workflow run ID."
|
|
return 1
|
|
fi
|
|
|
|
echo "Watching exact ${label} run ${run_id} in ${repo}."
|
|
|
|
while (( SECONDS < deadline )); do
|
|
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
|
|
|
|
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
|
|
|
|
# The R2 prefix must be identical across rerun attempts of this run:
|
|
# a rerun after a promotion-only failure has to reuse the packet the
|
|
# earlier attempt already uploaded instead of tripping the enterprise
|
|
# R2 overwrite guard. Run creation date and run id are stable across
|
|
# attempts; wall-clock date is not.
|
|
run_created_date="$(
|
|
gh run view "${GITHUB_RUN_ID}" \
|
|
--repo "${GITHUB_REPOSITORY}" \
|
|
--json createdAt \
|
|
--jq '.createdAt' | cut -c1-10 | tr -d '-'
|
|
)"
|
|
if [[ ! "${run_created_date}" =~ ^[0-9]{8}$ ]]; then
|
|
echo "::error::Could not derive the release run creation date for the R2 prefix."
|
|
exit 1
|
|
fi
|
|
r2_prefix="${TAG}-pro-${run_created_date}-${GITHUB_RUN_ID}"
|
|
echo "r2_prefix=${r2_prefix}" >> "$GITHUB_OUTPUT"
|
|
echo "Dispatching private Pro build for ${TAG} with R2 prefix ${r2_prefix}."
|
|
build_dispatch="$(
|
|
jq -n \
|
|
--arg pulse_ref "${TAG}" \
|
|
--arg version "${VERSION}" \
|
|
--arg r2_prefix "${r2_prefix}" \
|
|
--arg allow_stable_ga_publish "${allow_ga_publish}" \
|
|
'{
|
|
ref: "main",
|
|
return_run_details: true,
|
|
inputs: {
|
|
pulse_ref: $pulse_ref,
|
|
version: $version,
|
|
upload_actions_artifact: "false",
|
|
upload_to_r2: "true",
|
|
publish_docker_image: "true",
|
|
docker_image: "license.pulserelay.pro/pulse-pro",
|
|
r2_prefix: $r2_prefix,
|
|
reuse_existing_packet: "true",
|
|
allow_stable_ga_publish: $allow_stable_ga_publish
|
|
}
|
|
}' | \
|
|
gh api \
|
|
--method POST \
|
|
-H "Accept: application/vnd.github+json" \
|
|
-H "X-GitHub-Api-Version: 2026-03-10" \
|
|
repos/rcourtman/pulse-enterprise/actions/workflows/build-pro-release.yml/dispatches \
|
|
--input -
|
|
)"
|
|
build_run_id="$(jq -r '.workflow_run_id // empty' <<<"${build_dispatch}")"
|
|
wait_for_workflow rcourtman/pulse-enterprise "${build_run_id}" "private Pro build" 7200
|
|
|
|
# Durably enqueue customer convergence before crossing the irreversible
|
|
# publication boundary. The separate run waits for release-activation.json,
|
|
# so it cannot mutate a customer surface until public verification commits.
|
|
dispatch_release_convergence:
|
|
needs:
|
|
- prepare
|
|
- create_release
|
|
- release_readiness
|
|
- stage_private_pro_runtime
|
|
if: ${{ always() && needs.prepare.result == 'success' && needs.create_release.result == 'success' && needs.release_readiness.result == 'success' && ( !startsWith(needs.prepare.outputs.version, '6.') || needs.stage_private_pro_runtime.result == 'success' ) }}
|
|
runs-on: ubuntu-24.04
|
|
timeout-minutes: 10
|
|
permissions:
|
|
actions: write
|
|
contents: read
|
|
outputs:
|
|
run_id: ${{ steps.dispatch.outputs.run_id }}
|
|
run_url: ${{ steps.dispatch.outputs.run_url }}
|
|
steps:
|
|
- name: Dispatch durable customer convergence
|
|
id: dispatch
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
TAG: ${{ needs.prepare.outputs.tag }}
|
|
VERSION: ${{ needs.prepare.outputs.version }}
|
|
IS_PRERELEASE: ${{ needs.prepare.outputs.is_prerelease }}
|
|
TARGET_COMMITISH: ${{ needs.create_release.outputs.target_commitish }}
|
|
RELEASE_ID: ${{ needs.create_release.outputs.release_id }}
|
|
R2_PREFIX: ${{ needs.stage_private_pro_runtime.outputs.r2_prefix }}
|
|
run: |
|
|
set -euo pipefail
|
|
dispatch="$(
|
|
jq -n \
|
|
--arg tag "${TAG}" \
|
|
--arg version "${VERSION}" \
|
|
--arg prerelease "${IS_PRERELEASE}" \
|
|
--arg target_commitish "${TARGET_COMMITISH}" \
|
|
--arg release_id "${RELEASE_ID}" \
|
|
--arg r2_prefix "${R2_PREFIX}" \
|
|
--arg source_release_run_id "${GITHUB_RUN_ID}" \
|
|
'{
|
|
ref: "main",
|
|
return_run_details: true,
|
|
inputs: {
|
|
tag: $tag,
|
|
version: $version,
|
|
prerelease: $prerelease,
|
|
target_commitish: $target_commitish,
|
|
release_id: $release_id,
|
|
r2_prefix: $r2_prefix,
|
|
source_release_run_id: $source_release_run_id
|
|
}
|
|
}' | \
|
|
gh api \
|
|
--method POST \
|
|
-H "Accept: application/vnd.github+json" \
|
|
-H "X-GitHub-Api-Version: 2026-03-10" \
|
|
"repos/${{ github.repository }}/actions/workflows/release-convergence.yml/dispatches" \
|
|
--input -
|
|
)"
|
|
run_id="$(jq -r '.workflow_run_id // empty' <<<"${dispatch}")"
|
|
run_url="$(jq -r '.html_url // empty' <<<"${dispatch}")"
|
|
if [[ ! "${run_id}" =~ ^[0-9]+$ ]] || [ -z "${run_url}" ]; then
|
|
echo "::error::Customer convergence dispatch did not return an exact workflow run."
|
|
exit 1
|
|
fi
|
|
echo "run_id=${run_id}" >> "$GITHUB_OUTPUT"
|
|
echo "run_url=${run_url}" >> "$GITHUB_OUTPUT"
|
|
echo "[OK] Customer convergence is durably queued as ${run_url}."
|
|
|
|
# release-activation.json is the irreversible publication commit. Before the
|
|
# marker is public, any verification failure returns the release to draft.
|
|
# After the marker is public, customer convergence owns retriable rollout and
|
|
# the release is never described as rolled back merely because a mutable
|
|
# external surface is temporarily unavailable.
|
|
activate_release:
|
|
needs:
|
|
- prepare
|
|
- create_release
|
|
- release_readiness
|
|
- dispatch_release_convergence
|
|
- stage_private_pro_runtime
|
|
if: ${{ always() && needs.prepare.result == 'success' && needs.create_release.result == 'success' && needs.release_readiness.result == 'success' && needs.dispatch_release_convergence.result == 'success' }}
|
|
continue-on-error: true
|
|
runs-on: ubuntu-24.04
|
|
timeout-minutes: 15
|
|
permissions:
|
|
actions: read
|
|
contents: write
|
|
steps:
|
|
- name: Publish the fully staged release
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
TAG: ${{ needs.prepare.outputs.tag }}
|
|
RELEASE_ID: ${{ needs.create_release.outputs.release_id }}
|
|
EXPECTED_COMMIT: ${{ needs.create_release.outputs.target_commitish }}
|
|
IS_PRERELEASE: ${{ needs.prepare.outputs.is_prerelease }}
|
|
CONVERGENCE_RUN_ID: ${{ needs.dispatch_release_convergence.outputs.run_id }}
|
|
R2_PREFIX: ${{ needs.stage_private_pro_runtime.outputs.r2_prefix }}
|
|
run: |
|
|
set -euo pipefail
|
|
release_json=$(mktemp)
|
|
publish_payload=$(mktemp)
|
|
quarantine_payload=$(mktemp)
|
|
activation_marker_dir=$(mktemp -d)
|
|
activation_marker="${activation_marker_dir}/release-activation.json"
|
|
verified_marker=$(mktemp)
|
|
activated=false
|
|
committed=false
|
|
|
|
require_viable_convergence_owner() {
|
|
local attempt owner_state owner_event owner_status owner_conclusion
|
|
local owner_workflow owner_title owner_url expected_title
|
|
expected_title="Release convergence ${TAG} source ${GITHUB_RUN_ID}"
|
|
for attempt in $(seq 1 12); do
|
|
owner_state="$(
|
|
gh run view "${CONVERGENCE_RUN_ID}" \
|
|
--repo "${{ github.repository }}" \
|
|
--json event,status,conclusion,workflowName,displayTitle,url \
|
|
--jq '[.event, .status, (.conclusion // ""), .workflowName, .displayTitle, .url] | @tsv'
|
|
)"
|
|
owner_event="$(awk -F '\t' '{print $1}' <<<"${owner_state}")"
|
|
owner_status="$(awk -F '\t' '{print $2}' <<<"${owner_state}")"
|
|
owner_conclusion="$(awk -F '\t' '{print $3}' <<<"${owner_state}")"
|
|
owner_workflow="$(awk -F '\t' '{print $4}' <<<"${owner_state}")"
|
|
owner_title="$(awk -F '\t' '{print $5}' <<<"${owner_state}")"
|
|
owner_url="$(awk -F '\t' '{print $6}' <<<"${owner_state}")"
|
|
if [ "${owner_event}" = "workflow_dispatch" ] && \
|
|
[ "${owner_workflow}" = "Release Convergence" ] && \
|
|
[ "${owner_title}" = "${expected_title}" ] && \
|
|
[ "${owner_status}" != "completed" ] && \
|
|
[ -z "${owner_conclusion}" ]; then
|
|
echo "Verified viable convergence owner ${CONVERGENCE_RUN_ID}: ${owner_status} ${owner_url}."
|
|
return 0
|
|
fi
|
|
if [ "${owner_status}" = "completed" ] || [ -n "${owner_conclusion}" ]; then
|
|
echo "::error::Exact convergence owner ${CONVERGENCE_RUN_ID} is terminal for ${TAG}: status=${owner_status} conclusion=${owner_conclusion:-none} ${owner_url}."
|
|
return 1
|
|
fi
|
|
echo "Convergence owner ${CONVERGENCE_RUN_ID} metadata is not coherent yet (${attempt}/12); waiting for GitHub indexing."
|
|
sleep 2
|
|
done
|
|
echo "::error::Exact convergence owner ${CONVERGENCE_RUN_ID} metadata did not converge for ${TAG}: event=${owner_event:-missing} workflow=${owner_workflow:-missing} title=${owner_title:-missing} status=${owner_status:-missing} ${owner_url:-}."
|
|
return 1
|
|
}
|
|
|
|
quarantine_on_error() {
|
|
if [ "$activated" = "true" ] && [ "$committed" != "true" ]; then
|
|
echo "::warning::Public asset verification failed; returning ${TAG} to draft quarantine."
|
|
gh api "repos/${{ github.repository }}/releases/${RELEASE_ID}" \
|
|
-X PATCH --input "$quarantine_payload" >/dev/null || true
|
|
fi
|
|
}
|
|
trap quarantine_on_error ERR
|
|
|
|
gh api "repos/${{ github.repository }}/releases/${RELEASE_ID}" > "$release_json"
|
|
actual_tag=$(jq -r '.tag_name // ""' "$release_json")
|
|
actual_commit=$(jq -r '.target_commitish // ""' "$release_json")
|
|
actual_draft=$(jq -r '.draft' "$release_json")
|
|
published_at=$(jq -r '.published_at // ""' "$release_json")
|
|
actual_prerelease=$(jq -r '.prerelease' "$release_json")
|
|
activation_committed=$(jq -r 'any(.assets[]?; .name == "release-activation.json")' "$release_json")
|
|
if [ "$actual_tag" != "$TAG" ] || [ "$actual_commit" != "$EXPECTED_COMMIT" ] || \
|
|
[ "$actual_draft" != "true" ] || \
|
|
[ "$activation_committed" = "true" ] || \
|
|
[ "$actual_prerelease" != "$IS_PRERELEASE" ]; then
|
|
echo "::error::Release ${RELEASE_ID} no longer matches the staged activation candidate."
|
|
exit 1
|
|
fi
|
|
if [ -n "$published_at" ]; then
|
|
echo "Resuming quarantined activation for ${TAG}; GitHub retained historical published_at=${published_at}."
|
|
fi
|
|
|
|
make_latest=false
|
|
if [ "$IS_PRERELEASE" != "true" ]; then
|
|
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
|
|
make_latest=true
|
|
fi
|
|
fi
|
|
jq -n --arg make_latest "$make_latest" \
|
|
'{draft: false, make_latest: $make_latest}' > "$publish_payload"
|
|
jq -n '{draft: true, make_latest: "false"}' > "$quarantine_payload"
|
|
|
|
require_viable_convergence_owner
|
|
gh api "repos/${{ github.repository }}/releases/${RELEASE_ID}" \
|
|
-X PATCH --input "$publish_payload" > "$release_json"
|
|
activated=true
|
|
if [ "$(jq -r '.draft' "$release_json")" != "false" ] || \
|
|
[ -z "$(jq -r '.published_at // ""' "$release_json")" ]; then
|
|
echo "::error::GitHub did not publish release ${RELEASE_ID}."
|
|
exit 1
|
|
fi
|
|
|
|
base="https://github.com/${{ github.repository }}/releases/download/${TAG}"
|
|
for asset_name in \
|
|
checksums.txt \
|
|
install.sh \
|
|
"pulse-provider-msp-${TAG}.tar.gz" \
|
|
"pulse-${TAG}-linux-amd64.tar.gz"; do
|
|
curl -fsSL --retry 12 --retry-delay 5 --retry-all-errors \
|
|
-o /dev/null "${base}/${asset_name}"
|
|
done
|
|
|
|
# Close the dispatch-to-commit race: the exact durable convergence
|
|
# owner must still be queued or running immediately before the marker
|
|
# makes activation irreversible.
|
|
require_viable_convergence_owner
|
|
jq -n \
|
|
--arg tag "${TAG}" \
|
|
--arg target_commitish "${EXPECTED_COMMIT}" \
|
|
--arg release_id "${RELEASE_ID}" \
|
|
--arg source_release_run_id "${GITHUB_RUN_ID}" \
|
|
--arg convergence_run_id "${CONVERGENCE_RUN_ID}" \
|
|
--arg r2_prefix "${R2_PREFIX}" \
|
|
'{
|
|
schema_version: 1,
|
|
tag: $tag,
|
|
target_commitish: $target_commitish,
|
|
release_id: $release_id,
|
|
source_release_run_id: $source_release_run_id,
|
|
convergence_run_id: $convergence_run_id,
|
|
r2_prefix: $r2_prefix
|
|
}' > "${activation_marker}"
|
|
gh release upload "${TAG}" \
|
|
"${activation_marker}" --clobber \
|
|
--repo "${GITHUB_REPOSITORY}"
|
|
# The successful upload is the single irreversible logical boundary:
|
|
# convergence may observe the marker immediately, so no later
|
|
# activation-side read failure may return the release to draft.
|
|
committed=true
|
|
curl -fsSL --retry 12 --retry-delay 5 --retry-all-errors \
|
|
-o "${verified_marker}" "${base}/release-activation.json"
|
|
jq -e \
|
|
--arg tag "${TAG}" \
|
|
--arg target_commitish "${EXPECTED_COMMIT}" \
|
|
--arg release_id "${RELEASE_ID}" \
|
|
--arg source_release_run_id "${GITHUB_RUN_ID}" \
|
|
--arg convergence_run_id "${CONVERGENCE_RUN_ID}" \
|
|
--arg r2_prefix "${R2_PREFIX}" \
|
|
'.schema_version == 1 and .tag == $tag and .target_commitish == $target_commitish and .release_id == $release_id and .source_release_run_id == $source_release_run_id and .convergence_run_id == $convergence_run_id and .r2_prefix == $r2_prefix' \
|
|
"${verified_marker}" >/dev/null
|
|
trap - ERR
|
|
rm -f "$release_json" "$publish_payload" "$quarantine_payload" \
|
|
"$verified_marker"
|
|
rm -rf "$activation_marker_dir"
|
|
echo "[OK] Irreversibly committed and publicly verified ${TAG}; convergence run ${CONVERGENCE_RUN_ID} owns customer rollout."
|
|
|
|
release_commit_verdict:
|
|
name: Release Activation Commit Verdict
|
|
needs:
|
|
- prepare
|
|
- release_smoke
|
|
- windows_install_command_smoke
|
|
- create_release
|
|
- publish_docker
|
|
- validate_release_assets
|
|
- install_sh_smoke
|
|
- publish_helm_chart
|
|
- release_readiness
|
|
- stage_private_pro_runtime
|
|
- dispatch_release_convergence
|
|
- activate_release
|
|
if: ${{ always() && needs.prepare.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' }}
|
|
runs-on: ubuntu-24.04
|
|
steps:
|
|
- name: Enforce irreversible release commit outcome
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
DRAFT_ONLY: ${{ github.event.inputs.draft_only }}
|
|
VERSION: ${{ needs.prepare.outputs.version }}
|
|
TAG: ${{ needs.prepare.outputs.tag }}
|
|
EXPECTED_COMMIT: ${{ needs.create_release.outputs.target_commitish }}
|
|
RELEASE_ID: ${{ needs.create_release.outputs.release_id }}
|
|
CREATE_RESULT: ${{ needs.create_release.result }}
|
|
SMOKE_RESULT: ${{ needs.release_smoke.result }}
|
|
WINDOWS_INSTALL_COMMAND_RESULT: ${{ needs.windows_install_command_smoke.result }}
|
|
DOCKER_RESULT: ${{ needs.publish_docker.result }}
|
|
VALIDATE_RESULT: ${{ needs.validate_release_assets.result }}
|
|
INSTALL_RESULT: ${{ needs.install_sh_smoke.result }}
|
|
HELM_RESULT: ${{ needs.publish_helm_chart.result }}
|
|
READINESS_RESULT: ${{ needs.release_readiness.result }}
|
|
PRIVATE_PRO_STAGE_RESULT: ${{ needs.stage_private_pro_runtime.result }}
|
|
CONVERGENCE_DISPATCH_RESULT: ${{ needs.dispatch_release_convergence.result }}
|
|
CONVERGENCE_RUN_ID: ${{ needs.dispatch_release_convergence.outputs.run_id }}
|
|
CONVERGENCE_RUN_URL: ${{ needs.dispatch_release_convergence.outputs.run_url }}
|
|
R2_PREFIX: ${{ needs.stage_private_pro_runtime.outputs.r2_prefix }}
|
|
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 smoke" "$SMOKE_RESULT" success
|
|
require_result "Windows install command smoke" "$WINDOWS_INSTALL_COMMAND_RESULT" success
|
|
require_result "release staging" "$CREATE_RESULT" success
|
|
require_result "release asset validation" "$VALIDATE_RESULT" success
|
|
|
|
if [ "${DRAFT_ONLY:-false}" != "true" ]; then
|
|
require_result "exact-version Docker staging" "$DOCKER_RESULT" success
|
|
require_result "staged install.sh smoke" "$INSTALL_RESULT" success
|
|
require_result "Helm staging" "$HELM_RESULT" success
|
|
require_result "immutable release readiness" "$READINESS_RESULT" success
|
|
require_result "durable customer convergence dispatch" "$CONVERGENCE_DISPATCH_RESULT" success
|
|
if [[ "$VERSION" == 6.* ]]; then
|
|
require_result "private Pro staging" "$PRIVATE_PRO_STAGE_RESULT" success
|
|
fi
|
|
|
|
marker="$(mktemp)"
|
|
curl -fsSL --retry 6 --retry-delay 5 --retry-all-errors \
|
|
-o "${marker}" \
|
|
"https://github.com/${{ github.repository }}/releases/download/${TAG}/release-activation.json"
|
|
jq -e \
|
|
--arg tag "${TAG}" \
|
|
--arg target_commitish "${EXPECTED_COMMIT}" \
|
|
--arg release_id "${RELEASE_ID}" \
|
|
--arg source_release_run_id "${GITHUB_RUN_ID}" \
|
|
--arg convergence_run_id "${CONVERGENCE_RUN_ID}" \
|
|
--arg r2_prefix "${R2_PREFIX}" \
|
|
'.schema_version == 1 and .tag == $tag and .target_commitish == $target_commitish and .release_id == $release_id and .source_release_run_id == $source_release_run_id and .convergence_run_id == $convergence_run_id and .r2_prefix == $r2_prefix' \
|
|
"${marker}" >/dev/null
|
|
rm -f "${marker}"
|
|
fi
|
|
|
|
echo "Release activation commit passed for v${VERSION}."
|
|
if [ "${DRAFT_ONLY:-false}" != "true" ]; then
|
|
echo "Customer convergence continues independently in ${CONVERGENCE_RUN_URL}."
|
|
fi
|