Files
pulse/.github/workflows/create-release.yml
T
rcourtman ae5df19b62 Give release backend tests hosted-runner headroom
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.
2026-08-21 08:49:05 +01:00

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