Files
pulse/.github/workflows/create-release.yml
rcourtman ab562c82aa Encode the release source branch through the shared output helper
The branch is validated by the snapshot guard, but its transfer between
workflow steps must also use the canonical GitHub command-file encoder.
Keep the source binding unchanged and satisfy the workflow trust audit.

Validation: all 41 workflow trust tests and five snapshot tests pass.
Contract-Neutral: Encode the already-validated release branch with the shared GitHub command-file helper without changing source identity or release authority
2026-09-07 19:38:48 +01:00

2261 lines
104 KiB
YAML

name: Pulse Release Pipeline
# Alpha and beta use the fast preview path. RCs run the stable-depth release
# checks because an RC is a build the maintainer believes can become stable.
on:
workflow_dispatch:
inputs:
version:
description: 'Version number (e.g., 4.30.0)'
required: true
type: string
expected_source_sha:
description: 'Exact 40-character commit SHA admitted for this release'
required: true
type: string
release_source_branch:
description: 'Governed source branch for a merged immutable release snapshot'
required: false
type: string
release_pull_request:
description: 'Merged pull request that reviewed the immutable snapshot'
required: false
type: string
release_notes:
description: 'Release notes (markdown)'
required: true
type: string
release_screenshot_plan:
description: 'Validated model-selected release-note visual plan (JSON)'
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: 'Optional version-bound override after SignPath availability is restored; not required while the standing unavailable policy is active'
required: false
type: boolean
default: false
unsigned_windows_reason:
description: 'Owner reason for an explicit version-bound unsigned Windows override'
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:
permissions:
actions: read
contents: read
pull-requests: read
# Stable releases use hosted runners regardless of their Windows-signing
# decision. Prereleases retain the credential-free PVE acceleration path.
runs-on: ${{ !contains(inputs.version, '-') && 'ubuntu-24.04' || fromJSON('["self-hosted","Linux","X64","pulse-pve-compile"]') }}
timeout-minutes: 5
outputs:
version: ${{ steps.extract.outputs.version }}
tag: ${{ steps.extract.outputs.tag }}
is_prerelease: ${{ steps.extract.outputs.is_prerelease }}
release_stage: ${{ steps.promotion.outputs.release_stage }}
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 }}
visual_capture_count: ${{ steps.visual_plan.outputs.capture_count }}
visual_comparison_tag: ${{ steps.visual_plan.outputs.comparison_tag }}
steps:
- name: Verify admitted source commit
env:
EXPECTED_SOURCE_SHA: ${{ inputs.expected_source_sha }}
run: |
set -euo pipefail
if [[ ! "${EXPECTED_SOURCE_SHA}" =~ ^[0-9a-f]{40}$ ]]; then
echo "::error::expected_source_sha must be an exact 40-character commit SHA"
exit 1
fi
if [[ "${GITHUB_SHA}" != "${EXPECTED_SOURCE_SHA}" || \
"${GITHUB_WORKFLOW_SHA}" != "${EXPECTED_SOURCE_SHA}" ]]; then
echo "::error::Release dispatch expected ${EXPECTED_SOURCE_SHA}, but GitHub resolved source ${GITHUB_SHA} and workflow ${GITHUB_WORKFLOW_SHA}."
exit 1
fi
echo "[OK] Release dispatch is bound to ${EXPECTED_SOURCE_SHA}"
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
fetch-depth: 0
- name: Verify reviewed release snapshot
id: snapshot
env:
GH_TOKEN: ${{ github.token }}
RELEASE_SOURCE_BRANCH: ${{ inputs.release_source_branch }}
RELEASE_PULL_REQUEST: ${{ inputs.release_pull_request }}
run: python3 scripts/release_control/release_snapshot.py
- name: Extract version
id: extract
env:
VERSION_INPUT: ${{ inputs.version }}
HISTORICAL_ASSET_BACKFILL_INPUT: ${{ inputs.historical_asset_backfill_only }}
SNAPSHOT_SOURCE_BRANCH: ${{ steps.snapshot.outputs.source_branch }}
run: |
set -euo pipefail
if [[ ! "${VERSION_INPUT}" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-((rc|alpha|beta)\.[0-9]+))?$ ]]; then
echo "::error::workflow_dispatch must include an exact supported version"
exit 1
fi
if [[ "${HISTORICAL_ASSET_BACKFILL_INPUT}" != "true" && \
"${HISTORICAL_ASSET_BACKFILL_INPUT}" != "false" ]]; then
echo "::error::historical_asset_backfill_only must be true or false"
exit 1
fi
VERSION="${VERSION_INPUT}"
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="${SNAPSHOT_SOURCE_BRANCH}"
HISTORICAL_ASSET_BACKFILL_ONLY="${HISTORICAL_ASSET_BACKFILL_INPUT}"
python3 scripts/write_github_output.py tag "${TAG}"
python3 scripts/write_github_output.py version "${VERSION}"
echo "is_prerelease=${IS_PRERELEASE}" >> $GITHUB_OUTPUT
python3 scripts/write_github_output.py source_branch "${SOURCE_BRANCH}"
python3 scripts/write_github_output.py historical_asset_backfill_only "${HISTORICAL_ASSET_BACKFILL_ONLY}"
echo "Version: ${VERSION}, Tag: ${TAG}, Prerelease: ${IS_PRERELEASE}, Branch: ${SOURCE_BRANCH}, HistoricalBackfillOnly: ${HISTORICAL_ASSET_BACKFILL_ONLY}"
- name: Resolve required release branch
id: branch_policy
env:
WORKFLOW_OUTPUT_1: ${{ steps.extract.outputs.version }}
WORKFLOW_OUTPUT_2: ${{ steps.extract.outputs.source_branch }}
run: |
REQUIRED_BRANCH="$(python3 scripts/release_control/control_plane.py --branch-for-version "${WORKFLOW_OUTPUT_1}")"
if [ "${WORKFLOW_OUTPUT_2}" != "$REQUIRED_BRANCH" ]; then
echo "::error::Invalid release line. Version ${WORKFLOW_OUTPUT_1} must run from ${REQUIRED_BRANCH}, but workflow ref is ${WORKFLOW_OUTPUT_2}."
exit 1
fi
python3 scripts/write_github_output.py required_branch "${REQUIRED_BRANCH}"
echo "[OK] Governed release branch for ${WORKFLOW_OUTPUT_1} is ${REQUIRED_BRANCH}"
- name: Validate release-note visual plan
id: visual_plan
env:
WORKFLOW_OUTPUT_1: ${{ steps.extract.outputs.historical_asset_backfill_only }}
WORKFLOW_OUTPUT_2: ${{ steps.extract.outputs.version }}
run: |
set -euo pipefail
PLAN_FILE=$(mktemp)
if ! jq -er '.inputs.release_screenshot_plan | select(type == "string" and length > 0)' \
"$GITHUB_EVENT_PATH" > "$PLAN_FILE"; then
echo "::error::release_screenshot_plan must contain an evidence-backed visual decision"
exit 1
fi
python3 scripts/release_control/release_note_visuals.py \
validate --plan "$PLAN_FILE" --output "$PLAN_FILE"
CAPTURE_COUNT=$(python3 scripts/release_control/release_note_visuals.py \
count --plan "$PLAN_FILE")
COMPARISON_TAG=""
if [ "$CAPTURE_COUNT" -gt 0 ] && \
[ "${WORKFLOW_OUTPUT_1}" != "true" ]; then
COMPARISON_TAG=$(./scripts/generate-release-notes.sh \
--resolve-base "${WORKFLOW_OUTPUT_2}")
fi
echo "capture_count=${CAPTURE_COUNT}" >> "$GITHUB_OUTPUT"
echo "comparison_tag=${COMPARISON_TAG}" >> "$GITHUB_OUTPUT"
echo "[OK] Release-note visual plan contains ${CAPTURE_COUNT} capture(s)"
- name: Validate VERSION file
if: ${{ steps.extract.outputs.historical_asset_backfill_only != 'true' }}
env:
WORKFLOW_OUTPUT_1: ${{ steps.extract.outputs.version }}
run: |
FILE_VERSION=$(cat VERSION | tr -d '\n')
REQUESTED_VERSION="${WORKFLOW_OUTPUT_1}"
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 }}
WORKFLOW_OUTPUT_1: ${{ steps.extract.outputs.version }}
run: |
set -euo pipefail
python3 scripts/release_control/mobile_release_gate.py \
--version "${WORKFLOW_OUTPUT_1}" \
--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 }}
DRAFT_ONLY_INPUT: ${{ github.event.inputs.draft_only }}
GH_TOKEN: ${{ github.token }}
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
if [ "${DRAFT_ONLY_INPUT:-false}" != "true" ]; then
HELPER_ARGS+=(--enforce-prerelease-observation-window)
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}"
# Repository release immutability is configuration outside this commit. Prove
# that prerequisite on a GitHub-hosted runner before starting compilation,
# signing, private staging, or draft assembly. Activation repeats the same
# check immediately before publication so later setting drift still fails
# closed. Inert draft-only and historical-backfill runs do not publish and
# therefore do not require this repository setting.
publication_trust_preflight:
name: Publication Trust Preflight
needs: prepare
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Checkout release trust control
if: ${{ github.event.inputs.draft_only != 'true' && needs.prepare.outputs.historical_asset_backfill_only != 'true' }}
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Require immutable release publication capability
if: ${{ github.event.inputs.draft_only != 'true' && needs.prepare.outputs.historical_asset_backfill_only != 'true' }}
env:
GH_TOKEN: ${{ secrets.WORKFLOW_PAT }}
run: |
set -euo pipefail
if [ -z "${GH_TOKEN:-}" ]; then
echo "::error::WORKFLOW_PAT with repository Administration (read) is required to prove release immutability."
exit 1
fi
./scripts/check-github-release-immutability.sh "${GITHUB_REPOSITORY}"
- name: Confirm inert release mode
if: ${{ github.event.inputs.draft_only == 'true' || needs.prepare.outputs.historical_asset_backfill_only == 'true' }}
run: echo "Publication trust preflight is not required for an inert draft-only or historical-backfill run."
build_release_candidate:
name: Build Immutable Release Candidate
needs:
- prepare
- publication_trust_preflight
if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' }}
permissions:
actions: write
attestations: write
contents: read
id-token: write
uses: ./.github/workflows/build-release-candidate.yml
secrets: inherit
with:
version: ${{ needs.prepare.outputs.version }}
qualify_containers: false
require_macos_signing: true
require_windows_signing: ${{ needs.prepare.outputs.require_windows_signing == 'true' }}
windows_signing_backend: signpath
qualify_release_containers:
name: Qualify Exact-Candidate Containers
needs:
- prepare
- build_release_candidate
if: ${{ always() && needs.prepare.result == 'success' && needs.build_release_candidate.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' }}
permissions:
contents: read
uses: ./.github/workflows/qualify-release-containers.yml
with:
version: ${{ needs.prepare.outputs.version }}
container_artifact: ${{ needs.build_release_candidate.outputs.container_artifact_name }}
# Build the embed bundle independently so backend and smoke lanes can start
# without waiting for the full frontend quality suite.
frontend_bundle:
needs:
- prepare
- publication_trust_preflight
if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' }}
runs-on: ${{ !contains(inputs.version, '-') && 'ubuntu-24.04' || fromJSON('["self-hosted","Linux","X64","pulse-pve-build"]') }}
timeout-minutes: 10
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '24'
- name: Install dependencies
run: npm --prefix frontend-modern ci
- name: Build frontend bundle
run: npm --prefix frontend-modern run build
- name: Upload 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
# Frontend checks run independently from the bundle and backend lanes.
frontend_checks:
needs:
- prepare
- publication_trust_preflight
if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' }}
runs-on: ubuntu-24.04
timeout-minutes: 20
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '24'
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
windows_install_command_smoke:
name: Windows PowerShell 5.1 Install Command Smoke
needs:
- prepare
- publication_trust_preflight
if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' }}
runs-on: windows-2025
timeout-minutes: 15
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '24'
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
# The dedicated PVE test runner provides the memory needed to run two
# complete, disjoint internal/api shards while all other packages run in a
# third lane. It holds no signing or publication credentials.
backend_tests:
needs:
- prepare
- frontend_bundle
if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' }}
runs-on: ${{ !contains(inputs.version, '-') && 'ubuntu-24.04' || fromJSON('["self-hosted","Linux","X64","pulse-pve-tests"]') }}
# The rc.9 race-enabled API shards consumed more than 18 minutes on the PVE
# runner before post-step accounting. Keep the outer job above the canonical
# 45-minute API watchdog so checkout, bundle transfer, shard planning, and
# cleanup cannot pre-empt the process that owns stuck-package detection.
# Stable v6.4.2 rehearsal 33417470872 completed all three API shards in
# 32 minutes, then exhausted the former 55-minute ceiling while the
# independently bounded non-API graph was still passing packages. Keep
# every inner watchdog unchanged and leave enough outer cleanup headroom
# for the expanded secure-runtime install tests on a cold hosted worker.
timeout-minutes: 70
env:
FRONTEND_DIST: frontend-modern/dist
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- 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: false
- name: Run backend tests
run: ./scripts/run-release-backend-tests.sh --data-root "$RUNNER_TEMP/pulse-test-data"
# Alpha and beta builds are feedback checkpoints. RC and stable publication
# run the deeper integration gate because an RC must be promotable in intent.
integration_tests:
needs:
- prepare
- frontend_bundle
if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' && needs.prepare.outputs.release_stage != 'alpha' && needs.prepare.outputs.release_stage != 'beta' }}
runs-on: ubuntu-24.04
timeout-minutes: 45
env:
FRONTEND_DIST: frontend-modern/dist
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '24'
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_bundle
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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '24'
package-manager-cache: false
- 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
release_note_visuals:
needs:
- prepare
- publication_trust_preflight
if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' }}
runs-on: ubuntu-24.04
timeout-minutes: 45
permissions:
contents: read
steps:
- name: Checkout repository
if: ${{ needs.prepare.outputs.visual_capture_count != '0' }}
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
fetch-depth: 0
- name: Install browser capture runtime
if: ${{ needs.prepare.outputs.visual_capture_count != '0' }}
run: |
npm ci --ignore-scripts
npx playwright install --with-deps chromium
- name: Capture comparison and candidate views
if: ${{ needs.prepare.outputs.visual_capture_count != '0' }}
env:
WORKFLOW_OUTPUT_1: ${{ needs.prepare.outputs.visual_comparison_tag }}
run: |
set -euo pipefail
PLAN_FILE=$(mktemp)
jq -er '.inputs.release_screenshot_plan' "$GITHUB_EVENT_PATH" > "$PLAN_FILE"
bash scripts/capture-release-note-visuals.sh \
"$PLAN_FILE" \
"${WORKFLOW_OUTPUT_1}" \
release-note-visuals
- name: Upload release-note visual artifact
if: ${{ needs.prepare.outputs.visual_capture_count != '0' }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: release-note-visuals-${{ github.sha }}
path: release-note-visuals/*.png
if-no-files-found: error
retention-days: 14
create_release:
needs:
- prepare
- build_release_candidate
- release_note_visuals
# Draft metadata and immutable assets are inert staging. Qualification is
# joined at release_readiness before any activation boundary can open.
if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' && always() && needs.prepare.result == 'success' && needs.build_release_candidate.result == 'success' && needs.release_note_visuals.result == 'success' }}
runs-on: ubuntu-24.04
timeout-minutes: 30
permissions:
contents: 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: true # required: authenticated git writes
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: Download release-note visuals
if: ${{ needs.prepare.outputs.visual_capture_count != '0' }}
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: release-note-visuals-${{ github.sha }}
path: release-note-visuals
- name: Verify immutable release candidate
env:
WORKFLOW_OUTPUT_1: ${{ needs.prepare.outputs.version }}
run: |
python3 scripts/release_candidate_manifest.py verify-local \
--release-dir release \
--manifest release-candidate-manifest/release-candidate.json \
--version "${WORKFLOW_OUTPUT_1}" \
--source-sha "${GITHUB_SHA}"
- name: Prepare release notes
id: generate_notes
env:
WORKFLOW_OUTPUT_1: ${{ needs.prepare.outputs.version }}
WORKFLOW_OUTPUT_2: ${{ needs.prepare.outputs.tag }}
WORKFLOW_OUTPUT_3: ${{ needs.prepare.outputs.release_stage }}
WORKFLOW_OUTPUT_4: ${{ needs.prepare.outputs.promoted_from_tag }}
WORKFLOW_OUTPUT_5: ${{ needs.prepare.outputs.rollback_tag }}
WORKFLOW_OUTPUT_6: ${{ needs.prepare.outputs.rollback_command }}
WORKFLOW_OUTPUT_7: ${{ needs.prepare.outputs.ga_date }}
WORKFLOW_OUTPUT_8: ${{ needs.prepare.outputs.v5_eos_date }}
WORKFLOW_OUTPUT_9: ${{ needs.prepare.outputs.hotfix_exception }}
WORKFLOW_OUTPUT_10: ${{ needs.prepare.outputs.hotfix_reason }}
WORKFLOW_OUTPUT_11: ${{ needs.prepare.outputs.require_windows_signing }}
WORKFLOW_OUTPUT_12: ${{ needs.prepare.outputs.unsigned_windows_exception }}
WORKFLOW_OUTPUT_13: ${{ needs.prepare.outputs.unsigned_windows_reason }}
run: |
set -euo pipefail
VERSION="${WORKFLOW_OUTPUT_1}"
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)
VISUAL_PLAN_FILE=$(mktemp)
VISUAL_MARKDOWN_FILE=$(mktemp)
if ! jq -er '.inputs.release_screenshot_plan | select(type == "string" and length > 0)' \
"$GITHUB_EVENT_PATH" > "$VISUAL_PLAN_FILE"; then
echo "::error::release_screenshot_plan must contain an evidence-backed visual decision"
exit 1
fi
python3 scripts/release_control/release_note_visuals.py render \
--plan "$VISUAL_PLAN_FILE" \
--repository "${{ github.repository }}" \
--tag "${WORKFLOW_OUTPUT_2}" \
--output "$VISUAL_MARKDOWN_FILE"
python3 scripts/release_control/render_release_body.py \
--version "$VERSION" \
--release-notes-file "$NOTES_FILE" \
--release-visuals-file "$VISUAL_MARKDOWN_FILE" \
--output "$RENDERED_NOTES_FILE" \
--promotion-channel "${WORKFLOW_OUTPUT_3}" \
--candidate-tag "${WORKFLOW_OUTPUT_2}" \
--promoted-prerelease-tag "${WORKFLOW_OUTPUT_4}" \
--rollback-target "${WORKFLOW_OUTPUT_5}" \
--rollback-command "${WORKFLOW_OUTPUT_6}" \
--planned-ga-date "${WORKFLOW_OUTPUT_7}" \
--planned-v5-eos-date "${WORKFLOW_OUTPUT_8}" \
--hotfix-exception "${WORKFLOW_OUTPUT_9}" \
--hotfix-reason "${WORKFLOW_OUTPUT_10}" \
--require-windows-signing "${WORKFLOW_OUTPUT_11}" \
--unsigned-windows-exception "${WORKFLOW_OUTPUT_12}" \
--unsigned-windows-reason "${WORKFLOW_OUTPUT_13}"
# Customer-facing improvements provide the compact pre-update preview.
# Historical Highlights sections remain supported for older packets.
if grep -qiE "^#{1,6}[[:space:]]+(highlights|what.?s improved)\\b" "$RENDERED_NOTES_FILE"; then
echo "::notice::Release notes include customer-facing improvements — the update banner can preview them before users update."
else
echo "::notice::Release notes have no customer-facing improvements — the update banner will not show a summary preview."
fi
if grep -qiE "^#{1,6}[[:space:]]+(what.?s improved|added|new features|improved|improvements|changed|fixed|fixes|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 }}
WORKFLOW_OUTPUT_1: ${{ needs.prepare.outputs.tag }}
run: |
TAG="${WORKFLOW_OUTPUT_1}"
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")')
python3 scripts/write_github_output.py release_id "${RELEASE_ID}"
python3 scripts/write_github_output.py release_url "${RELEASE_URL}"
python3 scripts/write_github_output.py release_is_draft "${RELEASE_IS_DRAFT}"
python3 scripts/write_github_output.py release_published_at "${RELEASE_PUBLISHED_AT}"
python3 scripts/write_github_output.py release_activation_committed "${RELEASE_ACTIVATION_COMMITTED}"
- name: Create tag
env:
GH_TOKEN: ${{ github.token }}
WORKFLOW_OUTPUT_1: ${{ needs.prepare.outputs.tag }}
WORKFLOW_OUTPUT_2: ${{ steps.existing_release.outputs.release_id }}
WORKFLOW_OUTPUT_3: ${{ steps.existing_release.outputs.release_is_draft }}
WORKFLOW_OUTPUT_4: ${{ steps.existing_release.outputs.release_published_at }}
WORKFLOW_OUTPUT_5: ${{ steps.existing_release.outputs.release_activation_committed }}
run: |
TAG="${WORKFLOW_OUTPUT_1}"
HEAD_SHA=$(git rev-parse HEAD)
EXISTING_RELEASE_ID="${WORKFLOW_OUTPUT_2}"
EXISTING_RELEASE_DRAFT="${WORKFLOW_OUTPUT_3}"
EXISTING_RELEASE_PUBLISHED_AT="${WORKFLOW_OUTPUT_4}"
EXISTING_RELEASE_ACTIVATION_COMMITTED="${WORKFLOW_OUTPUT_5}"
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 }}
WORKFLOW_OUTPUT_1: ${{ needs.prepare.outputs.tag }}
WORKFLOW_OUTPUT_2: ${{ steps.generate_notes.outputs.notes_file }}
WORKFLOW_OUTPUT_3: ${{ needs.prepare.outputs.is_prerelease }}
WORKFLOW_OUTPUT_4: ${{ steps.existing_release.outputs.release_id }}
WORKFLOW_OUTPUT_5: ${{ steps.existing_release.outputs.release_url }}
WORKFLOW_OUTPUT_6: ${{ steps.existing_release.outputs.release_is_draft }}
WORKFLOW_OUTPUT_7: ${{ steps.existing_release.outputs.release_published_at }}
WORKFLOW_OUTPUT_8: ${{ steps.existing_release.outputs.release_activation_committed }}
WORKFLOW_OUTPUT_9: ${{ needs.prepare.outputs.version }}
run: |
set -euo pipefail
TAG="${WORKFLOW_OUTPUT_1}"
NOTES_FILE="${WORKFLOW_OUTPUT_2}"
IS_PRERELEASE="${WORKFLOW_OUTPUT_3}"
HEAD_SHA=$(git rev-parse HEAD)
RELEASE_ID="${WORKFLOW_OUTPUT_4}"
RELEASE_URL="${WORKFLOW_OUTPUT_5}"
IS_DRAFT="${WORKFLOW_OUTPUT_6}"
PUBLISHED_AT="${WORKFLOW_OUTPUT_7}"
ACTIVATION_COMMITTED="${WORKFLOW_OUTPUT_8}"
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 "${WORKFLOW_OUTPUT_9}" \
--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
python3 scripts/write_github_output.py release_id "${RELEASE_ID}"
echo "[OK] Draft release: ${TAG} (ID: ${RELEASE_ID})"
- name: Upload checksums
env:
GH_TOKEN: ${{ github.token }}
WORKFLOW_OUTPUT_1: ${{ needs.prepare.outputs.tag }}
run: |
TAG="${WORKFLOW_OUTPUT_1}"
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-note visuals
if: ${{ needs.prepare.outputs.visual_capture_count != '0' }}
env:
GH_TOKEN: ${{ github.token }}
WORKFLOW_OUTPUT_1: ${{ needs.prepare.outputs.tag }}
run: |
set -euo pipefail
TAG="${WORKFLOW_OUTPUT_1}"
PLAN_FILE=$(mktemp)
jq -er '.inputs.release_screenshot_plan' "$GITHUB_EVENT_PATH" > "$PLAN_FILE"
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
sleep "$wait_seconds"
attempt=$((attempt + 1))
wait_seconds=$((wait_seconds * 2))
if [ "$wait_seconds" -gt 120 ]; then
wait_seconds=120
fi
done
}
while IFS= read -r asset_name; do
test -f "release-note-visuals/${asset_name}"
release_upload_with_retry "$TAG" "release-note-visuals/${asset_name}" --clobber
done < <(python3 scripts/release_control/release_note_visuals.py \
assets --plan "$PLAN_FILE")
- name: Upload release assets
env:
GH_TOKEN: ${{ github.token }}
WORKFLOW_OUTPUT_1: ${{ needs.prepare.outputs.tag }}
run: |
TAG="${WORKFLOW_OUTPUT_1}"
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
release_upload_with_retry \
"${TAG}" \
release/release-build-provenance.sigstore.json \
--clobber
release_upload_with_retry \
"${TAG}" \
release/secure-runtime-build-contract-v1.json \
release/secure-runtime-compiler-provenance.sigstore.json \
release/pulse-secure-runtime-collector-v1-linux-amd64 \
release/pulse-secure-runtime-collector-v2-linux-amd64 \
release/pulse-secure-runtime-collector-v3-linux-amd64 \
--clobber
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-helper-linux-amd64 \
release/pulse-agent-helper-linux-arm64 \
release/pulse-agent-helper-linux-armv7 \
release/pulse-agent-helper-linux-armv6 \
release/pulse-agent-helper-linux-386 \
release/pulse-agent-runner-linux-amd64 \
release/pulse-agent-runner-linux-arm64 \
release/pulse-agent-runner-linux-armv7 \
release/pulse-agent-runner-linux-armv6 \
release/pulse-agent-runner-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' }}
env:
WORKFLOW_OUTPUT_1: ${{ steps.create_release.outputs.release_url }}
run: 'echo "Draft-only mode: ${WORKFLOW_OUTPUT_1}"'
- name: Summary
env:
WORKFLOW_OUTPUT_1: ${{ needs.prepare.outputs.tag }}
WORKFLOW_OUTPUT_2: ${{ steps.create_release.outputs.release_url }}
run: |
echo "[SUCCESS] Release assets staged behind an unpublished draft."
echo "Release: ${WORKFLOW_OUTPUT_1}"
echo "URL: ${WORKFLOW_OUTPUT_2}"
backfill_release_assets:
needs:
- prepare
- publication_trust_preflight
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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: go.mod
cache: false
- 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 }}
WORKFLOW_OUTPUT_1: ${{ needs.prepare.outputs.tag }}
run: |
./scripts/backfill-release-assets.sh --tag "${WORKFLOW_OUTPUT_1}" --repo "${{ github.repository }}"
- name: Validate published release packet
env:
PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }}
WORKFLOW_OUTPUT_1: ${{ needs.prepare.outputs.tag }}
run: |
./scripts/validate-published-release.sh "${WORKFLOW_OUTPUT_1}" "${{ github.repository }}"
- name: Summary
env:
WORKFLOW_OUTPUT_1: ${{ needs.prepare.outputs.tag }}
run: |
echo "[SUCCESS] Historical release assets repaired"
echo "Release: ${WORKFLOW_OUTPUT_1}"
publish_docker:
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' && 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 }}
container_artifact: ${{ needs.build_release_candidate.outputs.container_artifact_name }}
source_sha: ${{ github.sha }}
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
id-token: write
attestations: 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
- publication_trust_preflight
- build_release_candidate
- qualify_release_containers
- frontend_bundle
- frontend_checks
- windows_install_command_smoke
- backend_tests
- integration_tests
- release_smoke
- create_release
- publish_docker
- validate_release_assets
- install_sh_smoke
- publish_helm_chart
- stage_private_pro_runtime
if: ${{ always() && needs.prepare.result == 'success' && needs.publication_trust_preflight.result == 'success' && needs.build_release_candidate.result == 'success' && needs.qualify_release_containers.result == 'success' && needs.frontend_bundle.result == 'success' && needs.frontend_checks.result == 'success' && needs.windows_install_command_smoke.result == 'success' && needs.backend_tests.result == 'success' && needs.release_smoke.result == 'success' && (needs.integration_tests.result == 'success' || needs.integration_tests.result == 'skipped') && 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 from the anticipated
# tag and immutable public SHA as soon as preparation succeeds. These assets
# remain inert until public readiness and activation allow the separate
# convergence workflow to update the live paid-runtime broker manifest.
stage_private_pro_runtime:
needs:
- prepare
- publication_trust_preflight
if: ${{ always() && needs.prepare.result == 'success' && needs.publication_trust_preflight.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: Checkout private-runtime staging control
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- 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 5
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}"
python3 scripts/write_github_output.py r2_prefix "${r2_prefix}"
echo "Dispatching private Pro build for ${TAG} with R2 prefix ${r2_prefix}."
build_dispatch="$(
jq -n \
--arg pulse_ref "${TAG}" \
--arg pulse_checkout_ref "${GITHUB_SHA}" \
--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,
pulse_checkout_ref: $pulse_checkout_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,
allow_pre_activation_staging: "true"
}
}' | \
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
- stage_private_pro_runtime
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.') || 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 staged and digest-checked while the release is a
# draft. Publishing that complete packet is the irreversible commit: GitHub
# must lock its tag/assets and issue a verifiable release attestation before
# customer convergence may use the marker.
activate_release:
needs:
- prepare
- create_release
- publish_docker
- publish_helm_chart
- 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: write
contents: write
outputs:
secure_runtime_qualification_run_id: ${{ steps.secure_runtime_qualification.outputs.run_id }}
secure_runtime_qualification_run_url: ${{ steps.secure_runtime_qualification.outputs.run_url }}
steps:
- name: Checkout release integrity control
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Publish the fully staged release
env:
GH_TOKEN: ${{ github.token }}
IMMUTABILITY_ADMIN_TOKEN: ${{ secrets.WORKFLOW_PAT }}
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 }}
SERVER_IMAGE_DIGEST: ${{ needs.publish_docker.outputs.server_digest }}
CONTROL_PLANE_IMAGE_DIGEST: ${{ needs.publish_docker.outputs.control_plane_digest }}
HELM_CHART_DIGEST: ${{ needs.publish_helm_chart.outputs.chart_digest }}
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
marker_staged=false
validate_existing_activation_commit() {
local marker_convergence_run_id recovery_run_id recovery_run expected_title
local convergence_run expected_convergence_title
curl -fsSL --retry 12 --retry-delay 5 --retry-all-errors \
-o "${verified_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 r2_prefix "${R2_PREFIX}" \
--arg server_image_digest "${SERVER_IMAGE_DIGEST}" \
--arg control_plane_image_digest "${CONTROL_PLANE_IMAGE_DIGEST}" \
--arg helm_chart_digest "${HELM_CHART_DIGEST}" \
'.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 | test("^[0-9]+$")) and .r2_prefix == $r2_prefix and
.server_image_digest == $server_image_digest and
.control_plane_image_digest == $control_plane_image_digest and
.helm_chart_digest == $helm_chart_digest' \
"${verified_marker}" >/dev/null
marker_convergence_run_id="$(jq -r '.convergence_run_id' "${verified_marker}")"
recovery_run_id="$(jq -r '.activation_recovery_run_id // ""' "${verified_marker}")"
if [ "${marker_convergence_run_id}" = "${CONVERGENCE_RUN_ID}" ] && \
[ -z "${recovery_run_id}" ]; then
echo "[OK] ${TAG} already has this release run's exact activation commit."
return 0
fi
if [[ ! "${recovery_run_id}" =~ ^[0-9]+$ ]]; then
echo "::error::Existing activation marker for ${TAG} has no valid recovery lineage."
return 1
fi
recovery_run="$(mktemp)"
gh api "repos/${{ github.repository }}/actions/runs/${recovery_run_id}" > "${recovery_run}"
expected_title="Recover release activation ${TAG} source ${GITHUB_RUN_ID}"
jq -e \
--arg repository "${GITHUB_REPOSITORY}" \
--arg title "${expected_title}" \
'.event == "workflow_dispatch" and
.path == ".github/workflows/recover-release-activation.yml" and
.head_branch == "main" and .head_repository.full_name == $repository and
.display_title == $title and .status == "completed" and .conclusion == "success"' \
"${recovery_run}" >/dev/null
convergence_run="$(mktemp)"
gh api "repos/${{ github.repository }}/actions/runs/${marker_convergence_run_id}" > "${convergence_run}"
expected_convergence_title="Release convergence ${TAG} source ${GITHUB_RUN_ID}"
jq -e \
--arg repository "${GITHUB_REPOSITORY}" \
--arg title "${expected_convergence_title}" \
'.event == "workflow_dispatch" and
.path == ".github/workflows/release-convergence.yml" and
.head_branch == "main" and .head_repository.full_name == $repository and
.display_title == $title' \
"${convergence_run}" >/dev/null
rm -f "${recovery_run}" "${convergence_run}"
echo "[OK] ${TAG} was already committed by successful recovery run ${recovery_run_id}; convergence run ${marker_convergence_run_id} owns customer rollout."
}
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
}
compensate_uncommitted_activation() {
if [ "$activated" = "true" ] && [ "$committed" != "true" ]; then
echo "::warning::Release publication did not become immutable; returning ${TAG} to draft quarantine."
gh api "repos/${{ github.repository }}/releases/${RELEASE_ID}" \
-X PATCH --input "$quarantine_payload" >/dev/null || true
fi
if [ "$marker_staged" = "true" ] && [ "$committed" != "true" ]; then
marker_asset_id="$(
gh api --paginate \
"repos/${{ github.repository }}/releases/${RELEASE_ID}/assets?per_page=100" \
--jq '.[] | select(.name == "release-activation.json") | .id' \
2>/dev/null || true
)"
if [[ "$marker_asset_id" =~ ^[0-9]+$ ]]; then
gh api -X DELETE \
"repos/${{ github.repository }}/releases/assets/${marker_asset_id}" \
>/dev/null || true
fi
fi
}
trap compensate_uncommitted_activation 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")
actual_immutable=$(jq -r '.immutable // false' "$release_json")
activation_committed=$(jq -r 'any(.assets[]?; .name == "release-activation.json")' "$release_json")
if [ "$actual_tag" = "$TAG" ] && [ "$actual_commit" = "$EXPECTED_COMMIT" ] && \
[ "$actual_draft" = "false" ] && [ -n "$published_at" ] && \
[ "$activation_committed" = "true" ] && \
[ "$actual_immutable" = "true" ] && \
[ "$actual_prerelease" = "$IS_PRERELEASE" ]; then
validate_existing_activation_commit
./scripts/verify-github-release-integrity.sh \
"$TAG" "${GITHUB_REPOSITORY}" "$RELEASE_ID" "$EXPECTED_COMMIT" \
"${verified_marker}"
rm -f "$release_json" "$publish_payload" "$quarantine_payload" \
"$verified_marker"
rm -rf "$activation_marker_dir"
exit 0
fi
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"
# Close the dispatch-to-commit race before staging the exact marker.
require_viable_convergence_owner
if [[ ! "${SERVER_IMAGE_DIGEST}" =~ ^sha256:[0-9a-f]{64}$ ]] || \
[[ ! "${CONTROL_PLANE_IMAGE_DIGEST}" =~ ^sha256:[0-9a-f]{64}$ ]] || \
[[ ! "${HELM_CHART_DIGEST}" =~ ^sha256:[0-9a-f]{64}$ ]]; then
echo "::error::Verified public container and Helm chart digests are required before release activation."
exit 1
fi
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}" \
--arg server_image_digest "${SERVER_IMAGE_DIGEST}" \
--arg control_plane_image_digest "${CONTROL_PLANE_IMAGE_DIGEST}" \
--arg helm_chart_digest "${HELM_CHART_DIGEST}" \
'{
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,
server_image_digest: $server_image_digest,
control_plane_image_digest: $control_plane_image_digest,
helm_chart_digest: $helm_chart_digest
}' > "${activation_marker}"
gh release upload "${TAG}" \
"${activation_marker}" --clobber \
--repo "${GITHUB_REPOSITORY}"
marker_staged=true
# GitHub exposes a SHA-256 digest for draft assets. Verify the exact
# marker bytes before publication makes the asset set unchangeable.
gh api "repos/${{ github.repository }}/releases/${RELEASE_ID}" > "$release_json"
expected_marker_digest="sha256:$(sha256sum "${activation_marker}" | awk '{print $1}')"
actual_marker_digest="$(
jq -er \
'[.assets[] | select(.name == "release-activation.json" and .state == "uploaded")] |
if length == 1 then .[0].digest else error("expected exactly one activation marker") end |
select(test("^sha256:[0-9a-f]{64}$"))' \
"$release_json"
)"
if [ "$actual_marker_digest" != "$expected_marker_digest" ]; then
echo "::error::Draft activation marker digest does not match the staged bytes."
exit 1
fi
# Publication is now the only irreversible boundary. GitHub must
# confirm the repository setting before publication and report the
# complete release as immutable afterward. The immediate setting
# check prevents a mutable public interval if configuration drifts;
# the response check remains defense in depth.
require_viable_convergence_owner
if [ -z "${IMMUTABILITY_ADMIN_TOKEN:-}" ]; then
echo "::error::WORKFLOW_PAT with repository Administration (read) is required to prove release immutability."
exit 1
fi
GH_TOKEN="${IMMUTABILITY_ADMIN_TOKEN}" \
./scripts/check-github-release-immutability.sh "${GITHUB_REPOSITORY}"
unset IMMUTABILITY_ADMIN_TOKEN
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")" ] || \
[ "$(jq -r '.immutable // false' "$release_json")" != "true" ]; then
echo "::error::GitHub did not publish ${TAG} as an immutable release. Enable repository release immutability before activation."
exit 1
fi
committed=true
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
visual_plan=$(mktemp)
if jq -er '.inputs.release_screenshot_plan | select(type == "string" and length > 0)' \
"$GITHUB_EVENT_PATH" > "$visual_plan"; then
while IFS= read -r asset_name; do
curl -fsSL --retry 12 --retry-delay 5 --retry-all-errors \
-o /dev/null "${base}/${asset_name}"
done < <(jq -r '
.captures[] |
(if .before == null then empty else "release-note-\(.id)-before.png" end),
"release-note-\(.id)-now.png"
' "$visual_plan")
fi
rm -f "$visual_plan"
curl -fsSL --retry 12 --retry-delay 5 --retry-all-errors \
-o "${verified_marker}" "${base}/release-activation.json"
./scripts/verify-github-release-integrity.sh \
"$TAG" "${GITHUB_REPOSITORY}" "$RELEASE_ID" "$EXPECTED_COMMIT" \
"${verified_marker}"
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}" \
--arg server_image_digest "${SERVER_IMAGE_DIGEST}" \
--arg control_plane_image_digest "${CONTROL_PLANE_IMAGE_DIGEST}" \
--arg helm_chart_digest "${HELM_CHART_DIGEST}" \
'.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 and .server_image_digest == $server_image_digest and .control_plane_image_digest == $control_plane_image_digest and .helm_chart_digest == $helm_chart_digest' \
"${verified_marker}" >/dev/null
trap - ERR
rm -f "$release_json" "$publish_payload" "$quarantine_payload" \
"$verified_marker"
rm -rf "$activation_marker_dir"
echo "[OK] Immutably committed, attested, and publicly verified ${TAG}; convergence run ${CONVERGENCE_RUN_ID} owns customer rollout."
# A release published with GITHUB_TOKEN does not emit a workflow-triggering
# release event. Dispatch the qualification explicitly after the immutable
# packet and activation marker have both been verified.
- name: Dispatch exact RC secure-runtime qualification
id: secure_runtime_qualification
if: ${{ needs.prepare.outputs.is_prerelease == 'true' && contains(needs.prepare.outputs.version, '-rc.') }}
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.prepare.outputs.tag }}
run: |
set -euo pipefail
[[ "${TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[1-9][0-9]*$ ]]
dispatch="$(
jq -n \
--arg tag "${TAG}" \
'{ref: $tag, return_run_details: true, inputs: {tag: $tag}}' | \
gh api \
--method POST \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2026-03-10" \
"repos/${GITHUB_REPOSITORY}/actions/workflows/qualify-secure-runtime-release.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::Secure-runtime qualification 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] Secure-runtime qualification is durably queued as ${run_url}."
release_commit_verdict:
name: Release Activation Commit Verdict
needs:
- prepare
- publication_trust_preflight
- 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
timeout-minutes: 10
steps:
- name: Checkout release integrity control
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- 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 }}
PUBLICATION_TRUST_RESULT: ${{ needs.publication_trust_preflight.result }}
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 }}
SECURE_RUNTIME_QUALIFICATION_RUN_ID: ${{ needs.activate_release.outputs.secure_runtime_qualification_run_id }}
SECURE_RUNTIME_QUALIFICATION_RUN_URL: ${{ needs.activate_release.outputs.secure_runtime_qualification_run_url }}
R2_PREFIX: ${{ needs.stage_private_pro_runtime.outputs.r2_prefix }}
SERVER_IMAGE_DIGEST: ${{ needs.publish_docker.outputs.server_digest }}
CONTROL_PLANE_IMAGE_DIGEST: ${{ needs.publish_docker.outputs.control_plane_digest }}
HELM_CHART_DIGEST: ${{ needs.publish_helm_chart.outputs.chart_digest }}
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 "publication trust preflight" "$PUBLICATION_TRUST_RESULT" success
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" =~ -rc\.[1-9][0-9]*$ ]]; then
if [[ ! "$SECURE_RUNTIME_QUALIFICATION_RUN_ID" =~ ^[0-9]+$ ]] || \
[[ ! "$SECURE_RUNTIME_QUALIFICATION_RUN_URL" =~ ^https://github\.com/${GITHUB_REPOSITORY}/actions/runs/[0-9]+$ ]]; then
echo "::error::Immutable RC publication did not retain an exact secure-runtime qualification run identity."
exit 1
fi
echo "[OK] Secure-runtime qualification run: ${SECURE_RUNTIME_QUALIFICATION_RUN_URL}"
fi
if [[ "$VERSION" == 6.* ]]; then
require_result "private Pro staging" "$PRIVATE_PRO_STAGE_RESULT" success
fi
./scripts/verify-github-release-integrity.sh \
"$TAG" "${GITHUB_REPOSITORY}" "$RELEASE_ID" "$EXPECTED_COMMIT"
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 r2_prefix "${R2_PREFIX}" \
--arg server_image_digest "${SERVER_IMAGE_DIGEST}" \
--arg control_plane_image_digest "${CONTROL_PLANE_IMAGE_DIGEST}" \
--arg helm_chart_digest "${HELM_CHART_DIGEST}" \
'.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 | test("^[0-9]+$")) and .r2_prefix == $r2_prefix and
.server_image_digest == $server_image_digest and
.control_plane_image_digest == $control_plane_image_digest and
.helm_chart_digest == $helm_chart_digest' \
"${marker}" >/dev/null
marker_convergence_run_id="$(jq -r '.convergence_run_id' "${marker}")"
recovery_run_id="$(jq -r '.activation_recovery_run_id // ""' "${marker}")"
if [ "${marker_convergence_run_id}" != "${CONVERGENCE_RUN_ID}" ] || \
[ -n "${recovery_run_id}" ]; then
if [[ ! "${recovery_run_id}" =~ ^[0-9]+$ ]]; then
echo "::error::Activation marker for ${TAG} does not belong to the staged convergence owner or a qualified recovery."
exit 1
fi
recovery_run="$(mktemp)"
gh api "repos/${{ github.repository }}/actions/runs/${recovery_run_id}" > "${recovery_run}"
jq -e \
--arg repository "${GITHUB_REPOSITORY}" \
--arg title "Recover release activation ${TAG} source ${GITHUB_RUN_ID}" \
'.event == "workflow_dispatch" and
.path == ".github/workflows/recover-release-activation.yml" and
.head_branch == "main" and .head_repository.full_name == $repository and
.display_title == $title and .status == "completed" and .conclusion == "success"' \
"${recovery_run}" >/dev/null
convergence_run="$(mktemp)"
gh api "repos/${{ github.repository }}/actions/runs/${marker_convergence_run_id}" > "${convergence_run}"
jq -e \
--arg repository "${GITHUB_REPOSITORY}" \
--arg title "Release convergence ${TAG} source ${GITHUB_RUN_ID}" \
'.event == "workflow_dispatch" and
.path == ".github/workflows/release-convergence.yml" and
.head_branch == "main" and .head_repository.full_name == $repository and
.display_title == $title' \
"${convergence_run}" >/dev/null
rm -f "${recovery_run}" "${convergence_run}"
echo "Release activation was committed by qualified recovery run ${recovery_run_id}; customer convergence continues in run ${marker_convergence_run_id}."
fi
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