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@820762786026740c76f36085b0efc47a31fe5020 # v7.0.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@820762786026740c76f36085b0efc47a31fe5020 # v7.0.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@820762786026740c76f36085b0efc47a31fe5020 # v7.0.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@820762786026740c76f36085b0efc47a31fe5020 # v7.0.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@820762786026740c76f36085b0efc47a31fe5020 # v7.0.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