name: Pulse Release Pipeline # Optimized: parallel jobs, fast prerelease path on: workflow_dispatch: inputs: version: description: 'Version number (e.g., 4.30.0)' required: true type: string release_notes: description: 'Release notes (markdown)' required: true type: string promoted_from_tag: description: 'Stable only: prerelease tag being promoted (for example 6.0.0-rc.2)' required: false type: string rollback_version: description: 'Required: prior stable version to pin for rollback (for example 5.1.14 or v5.1.14)' required: true type: string ga_date: description: 'First stable v6.0.0 GA only: exact GA publish date (YYYY-MM-DD)' required: false type: string v5_eos_date: description: 'First stable v6.0.0 GA only: Pulse v5 end-of-support date (YYYY-MM-DD)' required: false type: string hotfix_exception: description: 'Stable only: bypass the 72-hour prerelease soak for urgent customer harm' required: false type: boolean default: false hotfix_reason: description: 'Stable only: reason for hotfix soak exception' required: false type: string unsigned_windows_exception: description: 'v6.1.0 or v6.1.1 only: publish Windows agents without Authenticode under a recorded owner exception' required: false type: boolean default: false unsigned_windows_reason: description: 'v6.1.0 or v6.1.1 only: owner reason for the unsigned Windows exception' required: false type: string historical_asset_backfill_only: description: 'Repair an already-published release packet in place without rebuilding binaries' required: false type: boolean default: false draft_only: description: 'Create draft release only (do not publish)' required: false type: boolean default: false mobile_release_decision: description: 'Required mobile impact decision: no-mobile-impact, existing-mobile-build-compatible, mobile-candidate-uploaded, or mobile-candidate-required' required: true type: string mobile_release_evidence: description: 'Evidence for existing-mobile-build-compatible or mobile-candidate-uploaded decisions' required: false type: string concurrency: group: release-${{ github.event.inputs.version || github.ref || github.run_id }} cancel-in-progress: false permissions: actions: read contents: read jobs: # Combined version extraction and validation (saves a checkout) prepare: runs-on: ubuntu-24.04 timeout-minutes: 5 outputs: version: ${{ steps.extract.outputs.version }} tag: ${{ steps.extract.outputs.tag }} is_prerelease: ${{ steps.extract.outputs.is_prerelease }} source_branch: ${{ steps.extract.outputs.source_branch }} required_branch: ${{ steps.branch_policy.outputs.required_branch }} promoted_from_tag: ${{ steps.promotion.outputs.promoted_from_tag }} rollback_tag: ${{ steps.promotion.outputs.rollback_tag }} rollback_command: ${{ steps.promotion.outputs.rollback_command }} ga_date: ${{ steps.promotion.outputs.ga_date }} v5_eos_date: ${{ steps.promotion.outputs.v5_eos_date }} hotfix_exception: ${{ steps.promotion.outputs.hotfix_exception }} hotfix_reason: ${{ steps.promotion.outputs.hotfix_reason }} require_windows_signing: ${{ steps.promotion.outputs.require_windows_signing }} unsigned_windows_exception: ${{ steps.promotion.outputs.unsigned_windows_exception }} unsigned_windows_reason: ${{ steps.promotion.outputs.unsigned_windows_reason }} promotion_mode: ${{ steps.promotion.outputs.promotion_mode }} is_stable_patch: ${{ steps.promotion.outputs.is_stable_patch }} historical_asset_backfill_only: ${{ steps.extract.outputs.historical_asset_backfill_only }} steps: - name: Extract version id: extract run: | VERSION=$(jq -r '.inputs.version // ""' "$GITHUB_EVENT_PATH" 2>/dev/null || echo "") if [ -z "$VERSION" ]; then echo "::error::workflow_dispatch must include a version input" exit 1 fi TAG="v${VERSION}" IS_PRERELEASE="false" if [[ "$VERSION" =~ -rc\.[0-9]+$ ]] || [[ "$VERSION" =~ -alpha\.[0-9]+$ ]] || [[ "$VERSION" =~ -beta\.[0-9]+$ ]]; then IS_PRERELEASE="true" echo "Detected prerelease version: ${VERSION}" fi if [[ "${GITHUB_REF}" != refs/heads/* ]]; then echo "::error::Release workflow must be dispatched from a branch ref (current ref: ${GITHUB_REF})." exit 1 fi SOURCE_BRANCH="${GITHUB_REF_NAME}" HISTORICAL_ASSET_BACKFILL_ONLY=$(jq -r '.inputs.historical_asset_backfill_only // "false"' "$GITHUB_EVENT_PATH" 2>/dev/null || echo "false") echo "tag=${TAG}" >> $GITHUB_OUTPUT echo "version=${VERSION}" >> $GITHUB_OUTPUT echo "is_prerelease=${IS_PRERELEASE}" >> $GITHUB_OUTPUT echo "source_branch=${SOURCE_BRANCH}" >> $GITHUB_OUTPUT echo "historical_asset_backfill_only=${HISTORICAL_ASSET_BACKFILL_ONLY}" >> $GITHUB_OUTPUT echo "Version: ${VERSION}, Tag: ${TAG}, Prerelease: ${IS_PRERELEASE}, Branch: ${SOURCE_BRANCH}, HistoricalBackfillOnly: ${HISTORICAL_ASSET_BACKFILL_ONLY}" - name: Checkout repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 0 sparse-checkout: | VERSION docs/release-control/control_plane.json scripts/release_control/control_plane.py scripts/release_control/mobile_release_gate.py scripts/release_control/repo_file_io.py - name: Resolve required release branch id: branch_policy run: | REQUIRED_BRANCH="$(python3 scripts/release_control/control_plane.py --branch-for-version "${{ steps.extract.outputs.version }}")" if [ "${{ steps.extract.outputs.source_branch }}" != "$REQUIRED_BRANCH" ]; then echo "::error::Invalid release line. Version ${{ steps.extract.outputs.version }} must run from ${REQUIRED_BRANCH}, but workflow ref is ${{ steps.extract.outputs.source_branch }}." exit 1 fi echo "required_branch=${REQUIRED_BRANCH}" >> "$GITHUB_OUTPUT" echo "[OK] Governed release branch for ${{ steps.extract.outputs.version }} is ${REQUIRED_BRANCH}" - name: Validate VERSION file if: ${{ steps.extract.outputs.historical_asset_backfill_only != 'true' }} run: | FILE_VERSION=$(cat VERSION | tr -d '\n') REQUESTED_VERSION="${{ steps.extract.outputs.version }}" if [ "$FILE_VERSION" != "$REQUESTED_VERSION" ]; then echo "::error::VERSION file ($FILE_VERSION) does not match requested version ($REQUESTED_VERSION)." echo "The VERSION file must be updated and committed before running release." exit 1 fi echo "[OK] VERSION file matches requested version ($REQUESTED_VERSION)" - name: Validate mobile release decision if: ${{ steps.extract.outputs.historical_asset_backfill_only != 'true' }} env: MOBILE_RELEASE_DECISION: ${{ github.event.inputs.mobile_release_decision }} MOBILE_RELEASE_EVIDENCE: ${{ github.event.inputs.mobile_release_evidence }} run: | set -euo pipefail python3 scripts/release_control/mobile_release_gate.py \ --version "${{ steps.extract.outputs.version }}" \ --decision "${MOBILE_RELEASE_DECISION}" \ --evidence "${MOBILE_RELEASE_EVIDENCE}" \ --github-annotations - name: Validate promotion policy if: ${{ steps.extract.outputs.historical_asset_backfill_only != 'true' }} id: promotion env: VERSION: ${{ steps.extract.outputs.version }} TAG: ${{ steps.extract.outputs.tag }} REQUIRED_BRANCH: ${{ steps.branch_policy.outputs.required_branch }} IS_PRERELEASE: ${{ steps.extract.outputs.is_prerelease }} PROMOTED_FROM_TAG_INPUT: ${{ github.event.inputs.promoted_from_tag }} ROLLBACK_VERSION_INPUT: ${{ github.event.inputs.rollback_version }} GA_DATE_INPUT: ${{ github.event.inputs.ga_date }} V5_EOS_DATE_INPUT: ${{ github.event.inputs.v5_eos_date }} HOTFIX_EXCEPTION_INPUT: ${{ github.event.inputs.hotfix_exception }} HOTFIX_REASON_INPUT: ${{ github.event.inputs.hotfix_reason }} UNSIGNED_WINDOWS_EXCEPTION_INPUT: ${{ github.event.inputs.unsigned_windows_exception }} UNSIGNED_WINDOWS_REASON_INPUT: ${{ github.event.inputs.unsigned_windows_reason }} run: | set -euo pipefail git fetch --prune origin main "${REQUIRED_BRANCH}" --tags NOTES_FILE="$(mktemp)" if ! jq -er '.inputs.release_notes | select(type == "string" and length > 0)' \ "$GITHUB_EVENT_PATH" > "$NOTES_FILE"; then echo "::error::release_notes must be a non-empty Markdown string" exit 1 fi HELPER_ARGS=( --version "${VERSION}" --promoted-from-tag "${PROMOTED_FROM_TAG_INPUT:-}" --rollback-version "${ROLLBACK_VERSION_INPUT:-}" --ga-date "${GA_DATE_INPUT:-}" --v5-eos-date "${V5_EOS_DATE_INPUT:-}" --hotfix-reason "${HOTFIX_REASON_INPUT:-}" --release-notes-file "$NOTES_FILE" ) if [ "${HOTFIX_EXCEPTION_INPUT:-false}" = "true" ]; then HELPER_ARGS+=(--hotfix-exception) fi if [ "${UNSIGNED_WINDOWS_EXCEPTION_INPUT:-false}" = "true" ]; then HELPER_ARGS+=( --unsigned-windows-exception --unsigned-windows-reason "${UNSIGNED_WINDOWS_REASON_INPUT:-}" ) elif [ -n "${UNSIGNED_WINDOWS_REASON_INPUT:-}" ]; then HELPER_ARGS+=(--unsigned-windows-reason "${UNSIGNED_WINDOWS_REASON_INPUT}") fi python3 scripts/release_control/resolve_release_promotion.py "${HELPER_ARGS[@]}" > "$RUNNER_TEMP/promotion-metadata.out" rm -f "$NOTES_FILE" { cat "$RUNNER_TEMP/promotion-metadata.out" } >> "$GITHUB_OUTPUT" echo "[OK] Promotion policy validated for ${TAG}" build_release_candidate: name: Build Immutable Release Candidate needs: prepare if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' }} permissions: actions: read contents: read uses: ./.github/workflows/build-release-candidate.yml secrets: inherit with: version: ${{ needs.prepare.outputs.version }} require_macos_signing: true require_windows_signing: ${{ needs.prepare.outputs.require_windows_signing == 'true' }} windows_signing_backend: signpath # Frontend checks run in parallel with backend tests frontend_checks: needs: prepare if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' }} runs-on: ubuntu-24.04 timeout-minutes: 10 steps: - name: Checkout repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Set up Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: '20' cache: 'npm' cache-dependency-path: 'frontend-modern/package-lock.json' - name: Install dependencies run: npm --prefix frontend-modern ci - name: Lint frontend run: npm --prefix frontend-modern run lint - name: Audit header composition run: npm --prefix frontend-modern run lint:headers - name: Check frontend copy-paste duplication run: npm --prefix frontend-modern run lint:cpd - name: Build verified frontend bundle run: npm --prefix frontend-modern run build - name: Upload verified frontend bundle uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: release-frontend-${{ github.sha }} path: frontend-modern/dist/ if-no-files-found: error retention-days: 1 compression-level: 0 overwrite: true # Backend tests run in parallel with frontend checks backend_tests: needs: - prepare - frontend_checks if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' }} runs-on: ubuntu-24.04 timeout-minutes: 30 env: FRONTEND_DIST: frontend-modern/dist steps: - name: Checkout repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Download verified frontend bundle uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: path: frontend-modern/dist name: release-frontend-${{ github.sha }} - name: Copy frontend to embed location run: | rm -rf internal/api/frontend-modern mkdir -p internal/api/frontend-modern cp -r frontend-modern/dist internal/api/frontend-modern/ - name: Set up Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: go.mod cache: true - name: Run backend tests env: PULSE_DATA_DIR: /tmp/pulse-test-data run: make test # Docker build - amd64 only for prereleases, multi-arch for stable docker_build: needs: prepare if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' }} runs-on: ubuntu-24.04 timeout-minutes: 30 permissions: contents: read packages: write steps: - name: Checkout repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Set up QEMU if: needs.prepare.outputs.is_prerelease != 'true' uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0 - name: Set up Docker Buildx uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 - name: Log in to GHCR uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Derive license public key Docker cache key id: license_key_cache env: PULSE_LICENSE_PUBLIC_KEY: ${{ secrets.PULSE_LICENSE_PUBLIC_KEY }} run: | set -euo pipefail decoded_len="$(printf '%s' "${PULSE_LICENSE_PUBLIC_KEY}" | base64 -d | wc -c | tr -d ' ')" if [ "${decoded_len}" != "32" ]; then echo "PULSE_LICENSE_PUBLIC_KEY must decode to 32 bytes." >&2 exit 1 fi key_sha256="$(printf '%s' "${PULSE_LICENSE_PUBLIC_KEY}" | base64 -d | sha256sum | awk '{print $1}')" echo "sha256=${key_sha256}" >> "${GITHUB_OUTPUT}" - name: Build Docker image (verify only) uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 with: context: . target: runtime # amd64 only for prereleases (faster), multi-arch for stable releases platforms: ${{ needs.prepare.outputs.is_prerelease == 'true' && 'linux/amd64' || 'linux/amd64,linux/arm64' }} push: false # Don't push staging images, just verify build provenance: mode=max sbom: true cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/pulse:buildcache cache-to: type=registry,ref=ghcr.io/${{ github.repository_owner }}/pulse:buildcache,mode=max build-args: | VERSION=${{ needs.prepare.outputs.tag }} PULSE_LICENSE_PUBLIC_KEY_SHA256=${{ steps.license_key_cache.outputs.sha256 }} PULSE_UPDATE_SIGNING_PUBLIC_KEY=${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }} secrets: | pulse_license_public_key=${{ secrets.PULSE_LICENSE_PUBLIC_KEY }} pulse_update_signing_key=${{ secrets.PULSE_UPDATE_SIGNING_KEY }} - name: Build Pulse agent image (verify only) uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 with: context: . file: ./Dockerfile target: agent_runtime platforms: ${{ needs.prepare.outputs.is_prerelease == 'true' && 'linux/amd64' || 'linux/amd64,linux/arm64' }} push: false provenance: mode=max sbom: true cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/pulse-agent:buildcache cache-to: type=registry,ref=ghcr.io/${{ github.repository_owner }}/pulse-agent:buildcache,mode=max build-args: | VERSION=${{ needs.prepare.outputs.tag }} PULSE_LICENSE_PUBLIC_KEY_SHA256=${{ steps.license_key_cache.outputs.sha256 }} PULSE_UPDATE_SIGNING_PUBLIC_KEY=${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }} secrets: | pulse_license_public_key=${{ secrets.PULSE_LICENSE_PUBLIC_KEY }} pulse_update_signing_key=${{ secrets.PULSE_UPDATE_SIGNING_KEY }} helm_smoke: needs: prepare if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' }} runs-on: ubuntu-24.04 timeout-minutes: 30 steps: - name: Checkout repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Set up Helm uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 # v5.0.0 with: version: v3.15.2 - name: Build local Pulse runtime image for Helm smoke env: DOCKER_BUILDKIT: 1 PULSE_LICENSE_PUBLIC_KEY: ${{ secrets.PULSE_LICENSE_PUBLIC_KEY }} PULSE_UPDATE_SIGNING_KEY: ${{ secrets.PULSE_UPDATE_SIGNING_KEY }} PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }} run: | PULSE_LICENSE_PUBLIC_KEY_SHA256="$(printf '%s' "${PULSE_LICENSE_PUBLIC_KEY}" | base64 -d | sha256sum | awk '{print $1}')" docker build \ --target runtime \ --secret id=pulse_license_public_key,env=PULSE_LICENSE_PUBLIC_KEY \ --secret id=pulse_update_signing_key,env=PULSE_UPDATE_SIGNING_KEY \ --build-arg VERSION="${{ needs.prepare.outputs.tag }}" \ --build-arg PULSE_LICENSE_PUBLIC_KEY_SHA256="${PULSE_LICENSE_PUBLIC_KEY_SHA256}" \ --build-arg PULSE_UPDATE_SIGNING_PUBLIC_KEY="${PULSE_UPDATE_SIGNING_PUBLIC_KEY}" \ -t pulse-helm-smoke:${{ needs.prepare.outputs.version }} \ . - name: Helm smoke test with local release-line image env: SMOKE_IMAGE_REPOSITORY: pulse-helm-smoke SMOKE_IMAGE_TAG: ${{ needs.prepare.outputs.version }} run: | set -euo pipefail cleanup() { kind delete cluster --name pulse-test >/dev/null 2>&1 || true } diagnose() { echo "::group::helm status" helm status pulse || true echo "::endgroup::" echo "::group::kubectl get all" kubectl get all -A || true echo "::endgroup::" echo "::group::kubectl describe pods" kubectl describe pods -A || true echo "::endgroup::" echo "::group::pod logs" pods=$(kubectl get pods -A -o name 2>/dev/null || true) for pod in $pods; do echo "### ${pod}" kubectl logs --all-containers=true --tail=200 "$pod" || true done echo "::endgroup::" echo "::group::events" kubectl get events -A --sort-by=.lastTimestamp || kubectl get events -A || true echo "::endgroup::" cleanup } trap 'diagnose' ERR curl -Lo ./kind https://kind.sigs.k8s.io/dl/v0.20.0/kind-linux-amd64 chmod +x ./kind sudo mv ./kind /usr/local/bin/kind kind create cluster --name pulse-test --wait 5m kind load docker-image "${SMOKE_IMAGE_REPOSITORY}:${SMOKE_IMAGE_TAG}" --name pulse-test helm install pulse deploy/helm/pulse \ --set persistence.enabled=false \ --set server.secretEnv.create=true \ --set server.secretEnv.data.API_TOKENS=test-token \ --set image.repository="${SMOKE_IMAGE_REPOSITORY}" \ --set image.tag="${SMOKE_IMAGE_TAG}" \ --set image.pullPolicy=Never \ --wait --timeout 5m --debug kubectl wait --for=condition=ready pod -l app.kubernetes.io/name=pulse --timeout=180s || (kubectl describe pods -l app.kubernetes.io/name=pulse && exit 1) kubectl get pods -l app.kubernetes.io/name=pulse helm upgrade pulse deploy/helm/pulse \ --set persistence.enabled=false \ --set server.secretEnv.create=true \ --set server.secretEnv.data.API_TOKENS=test-token \ --set image.repository="${SMOKE_IMAGE_REPOSITORY}" \ --set image.tag="${SMOKE_IMAGE_TAG}" \ --set image.pullPolicy=Never \ --wait --timeout 5m --debug trap - ERR cleanup echo "✓ Helm smoke test passed" # Integration tests - skipped for prereleases (they've been tested in CI) integration_tests: needs: - prepare - frontend_checks if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' && needs.prepare.outputs.is_prerelease != 'true' }} runs-on: ubuntu-24.04 timeout-minutes: 45 env: FRONTEND_DIST: frontend-modern/dist steps: - name: Checkout repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Set up Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: '20' cache: 'npm' cache-dependency-path: 'frontend-modern/package-lock.json' - name: Download verified frontend bundle uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: path: frontend-modern/dist name: release-frontend-${{ github.sha }} - name: Copy frontend to embed location run: | rm -rf internal/api/frontend-modern mkdir -p internal/api/frontend-modern cp -r frontend-modern/dist internal/api/frontend-modern/ - name: Set up Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: go.mod cache: true - name: Build Pulse Docker image for integration tests run: docker build -t pulse:test --target runtime . - name: Build mock GitHub server run: docker build -t pulse-mock-github:test tests/integration/mock-github-server - name: Install integration test dependencies working-directory: tests/integration run: | npm ci npx playwright install --with-deps chromium - name: Run integration tests working-directory: tests/integration env: MOCK_CHECKSUM_ERROR: "false" MOCK_NETWORK_ERROR: "false" MOCK_RATE_LIMIT: "false" MOCK_STALE_RELEASE: "false" PULSE_MULTI_TENANT_ENABLED: "true" PULSE_E2E_ENTITLEMENT_PROFILE: "multi-tenant" PULSE_E2E_BOOTSTRAP_TOKEN: 0123456789abcdef0123456789abcdef0123456789abcdef run: | docker compose -f docker-compose.test.yml up -d echo "Waiting for services to be healthy..." timeout 60 sh -c 'until docker inspect --format="{{json .State.Health.Status}}" pulse-mock-github | grep -q "healthy"; do sleep 2; done' timeout 60 sh -c 'until docker inspect --format="{{json .State.Health.Status}}" pulse-test-server | grep -q "healthy"; do sleep 2; done' for i in 1 2 3 4 5; do if curl -f -s http://localhost:7655/api/health > /dev/null 2>&1; then echo "Pulse server is reachable" break elif [ $i -eq 5 ]; then docker logs pulse-test-server || true exit 1 fi sleep 2 done node scripts/apply-entitlement-profile.mjs echo "Validating seeded bootstrap token..." BOOTSTRAP_STATUS=$(curl -s -o /tmp/bootstrap-token-validation.txt -w "%{http_code}" \ -X POST \ -H "Content-Type: application/json" \ --data "{\"token\":\"${PULSE_E2E_BOOTSTRAP_TOKEN}\"}" \ http://localhost:7655/api/security/validate-bootstrap-token || true) echo "Bootstrap token validation endpoint returned HTTP ${BOOTSTRAP_STATUS}" if [ "${BOOTSTRAP_STATUS}" != "204" ]; then cat /tmp/bootstrap-token-validation.txt || true docker logs pulse-test-server || true exit 1 fi echo "Running update API route smoke check..." STATUS=$(curl -s -o /tmp/update-status.json -w "%{http_code}" http://localhost:7655/api/updates/status || true) echo "Update status endpoint returned HTTP ${STATUS}" case "${STATUS}" in 200|401|403) ;; *) echo "Unexpected response from /api/updates/status" cat /tmp/update-status.json || true exit 1 ;; esac echo "Running current organization-sharing E2E suite..." npx playwright test \ tests/66-organization-sharing-approval-ui.spec.ts \ --project=chromium \ --reporter=list docker compose -f docker-compose.test.yml down -v - name: Collect integration diagnostics if: failure() working-directory: tests/integration run: | mkdir -p release-integration-diagnostics { echo "=== Docker containers ===" docker ps -a || true echo echo "=== Pulse test server logs ===" docker logs pulse-test-server 2>&1 || echo "No pulse-test-server container" echo echo "=== Mock GitHub server logs ===" docker logs pulse-mock-github 2>&1 || echo "No pulse-mock-github container" } | tee release-integration-diagnostics/docker.log - name: Upload integration Playwright report if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: release-integration-playwright-report path: tests/integration/playwright-report/ if-no-files-found: ignore retention-days: 14 - name: Upload integration failures if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: release-integration-failures path: | tests/integration/test-results/ tests/integration/release-integration-diagnostics/ if-no-files-found: ignore retention-days: 14 - name: Cleanup if: always() working-directory: tests/integration run: docker compose -f docker-compose.test.yml down -v || true # Create release after all checks pass create_release: needs: - prepare - build_release_candidate - frontend_checks - backend_tests - docker_build - helm_smoke - integration_tests # Run if integration_tests passed OR was skipped (prereleases) if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' && always() && needs.build_release_candidate.result == 'success' && needs.frontend_checks.result == 'success' && needs.backend_tests.result == 'success' && needs.docker_build.result == 'success' && needs.helm_smoke.result == 'success' && (needs.integration_tests.result == 'success' || needs.integration_tests.result == 'skipped') }} runs-on: ubuntu-24.04 timeout-minutes: 30 permissions: contents: write id-token: write attestations: write outputs: release_id: ${{ steps.create_release.outputs.release_id }} release_url: ${{ steps.create_release.outputs.release_url }} target_commitish: ${{ github.sha }} steps: - name: Checkout repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 0 - name: Download immutable release candidate uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: ${{ needs.build_release_candidate.outputs.artifact_name }} path: release - name: Download release candidate manifest uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: ${{ needs.build_release_candidate.outputs.manifest_artifact_name }} path: release-candidate-manifest - name: Verify immutable release candidate run: | python3 scripts/release_candidate_manifest.py verify-local \ --release-dir release \ --manifest release-candidate-manifest/release-candidate.json \ --version "${{ needs.prepare.outputs.version }}" \ --source-sha "${GITHUB_SHA}" - name: Attest release assets uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 with: subject-path: release/* - name: Prepare release notes id: generate_notes run: | set -euo pipefail VERSION="${{ needs.prepare.outputs.version }}" NOTES_FILE=$(mktemp) if ! jq -er '.inputs.release_notes | select(type == "string" and length > 0)' \ "$GITHUB_EVENT_PATH" > "$NOTES_FILE"; then echo "::error::release_notes must be a non-empty Markdown string" exit 1 fi RENDERED_NOTES_FILE=$(mktemp) python3 scripts/release_control/render_release_body.py \ --version "$VERSION" \ --release-notes-file "$NOTES_FILE" \ --output "$RENDERED_NOTES_FILE" \ --promotion-channel "${{ needs.prepare.outputs.is_prerelease == 'true' && 'rc' || 'stable' }}" \ --candidate-tag "${{ needs.prepare.outputs.tag }}" \ --promoted-prerelease-tag "${{ needs.prepare.outputs.promoted_from_tag }}" \ --rollback-target "${{ needs.prepare.outputs.rollback_tag }}" \ --rollback-command "${{ needs.prepare.outputs.rollback_command }}" \ --planned-ga-date "${{ needs.prepare.outputs.ga_date }}" \ --planned-v5-eos-date "${{ needs.prepare.outputs.v5_eos_date }}" \ --hotfix-exception "${{ needs.prepare.outputs.hotfix_exception }}" \ --hotfix-reason "${{ needs.prepare.outputs.hotfix_reason }}" \ --require-windows-signing "${{ needs.prepare.outputs.require_windows_signing }}" \ --unsigned-windows-exception "${{ needs.prepare.outputs.unsigned_windows_exception }}" \ --unsigned-windows-reason "${{ needs.prepare.outputs.unsigned_windows_reason }}" # The in-app "What's New" banner only fires when the release body has # a Highlights section (see frontend-modern/src/components/whatsNewModel.ts). # Surface which way this release will behave so silence is a choice, # not an accident. if grep -qiE '^#{1,6}[[:space:]]+highlights\b' "$RENDERED_NOTES_FILE"; then echo "::notice::Release notes include a Highlights section — the in-app What's New banner will show it after users update." else echo "::notice::Release notes have no Highlights section — the in-app What's New banner stays silent for this release (expected for maintenance releases)." fi echo "notes_file=${RENDERED_NOTES_FILE}" >> $GITHUB_OUTPUT - name: Locate existing release id: existing_release env: GH_TOKEN: ${{ github.token }} run: | TAG="${{ needs.prepare.outputs.tag }}" EXISTING_RELEASE=$(gh api "repos/${{ github.repository }}/releases?per_page=100" --paginate | jq -sc --arg tag "$TAG" 'add | map(select(.tag_name == $tag)) | first // empty') RELEASE_ID=$(echo "$EXISTING_RELEASE" | jq -r '.id // empty') RELEASE_URL=$(echo "$EXISTING_RELEASE" | jq -r '.html_url // empty') RELEASE_IS_DRAFT=$(echo "$EXISTING_RELEASE" | jq -r '.draft // false') RELEASE_PUBLISHED_AT=$(echo "$EXISTING_RELEASE" | jq -r '.published_at // empty') echo "release_id=${RELEASE_ID}" >> $GITHUB_OUTPUT echo "release_url=${RELEASE_URL}" >> $GITHUB_OUTPUT echo "release_is_draft=${RELEASE_IS_DRAFT}" >> $GITHUB_OUTPUT echo "release_published_at=${RELEASE_PUBLISHED_AT}" >> $GITHUB_OUTPUT - name: Create tag env: GH_TOKEN: ${{ github.token }} run: | TAG="${{ needs.prepare.outputs.tag }}" HEAD_SHA=$(git rev-parse HEAD) EXISTING_RELEASE_ID="${{ steps.existing_release.outputs.release_id }}" EXISTING_RELEASE_DRAFT="${{ steps.existing_release.outputs.release_is_draft }}" EXISTING_RELEASE_PUBLISHED_AT="${{ steps.existing_release.outputs.release_published_at }}" REMOTE_TAG_SHA=$(git ls-remote --tags origin "refs/tags/${TAG}" | awk '{print $1}') if [ -n "$REMOTE_TAG_SHA" ]; then REMOTE_COMMIT_SHA=$(git ls-remote --tags origin "refs/tags/${TAG}^{}" | awk '{print $1}') [ -z "$REMOTE_COMMIT_SHA" ] && REMOTE_COMMIT_SHA="$REMOTE_TAG_SHA" if [ "$REMOTE_COMMIT_SHA" = "$HEAD_SHA" ]; then echo "Tag ${TAG} already exists and points to HEAD - continuing" elif [ -n "$EXISTING_RELEASE_ID" ] && [ "$EXISTING_RELEASE_DRAFT" = "true" ] && [ -z "$EXISTING_RELEASE_PUBLISHED_AT" ]; then echo "Retargeting existing draft tag ${TAG} from ${REMOTE_COMMIT_SHA} to ${HEAD_SHA}" git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git tag -fa "${TAG}" -m "Release ${TAG}" "${HEAD_SHA}" git push origin "refs/tags/${TAG}" --force else echo "::error::Tag ${TAG} already exists but points to ${REMOTE_COMMIT_SHA}, not HEAD (${HEAD_SHA}). Delete the tag first: git push origin --delete ${TAG}" exit 1 fi else echo "Creating tag ${TAG}..." git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git tag -a "${TAG}" -m "Release ${TAG}" git push origin "${TAG}" fi - name: Create draft release id: create_release env: GH_TOKEN: ${{ github.token }} run: | set -euo pipefail TAG="${{ needs.prepare.outputs.tag }}" NOTES_FILE="${{ steps.generate_notes.outputs.notes_file }}" IS_PRERELEASE="${{ needs.prepare.outputs.is_prerelease }}" HEAD_SHA=$(git rev-parse HEAD) RELEASE_ID="${{ steps.existing_release.outputs.release_id }}" RELEASE_URL="${{ steps.existing_release.outputs.release_url }}" IS_DRAFT="${{ steps.existing_release.outputs.release_is_draft }}" PUBLISHED_AT="${{ steps.existing_release.outputs.release_published_at }}" 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" ] && [ -z "$PUBLISHED_AT" ]; then echo "Updating existing draft release for ${TAG}" gh api "repos/${{ github.repository }}/releases/${RELEASE_ID}" \ -X PATCH \ --input "$RELEASE_PAYLOAD" > "$RELEASE_JSON_FILE" else echo "::error::Published release already exists for ${TAG}." exit 1 fi else echo "Creating draft release for ${TAG}..." gh api "repos/${{ github.repository }}/releases" \ -X POST \ --input "$RELEASE_PAYLOAD" > "$RELEASE_JSON_FILE" RELEASE_ID=$(jq -r '.id' "$RELEASE_JSON_FILE") RELEASE_URL=$(jq -r '.html_url' "$RELEASE_JSON_FILE") fi gh api "repos/${{ github.repository }}/releases/${RELEASE_ID}" > "$RELEASE_JSON_FILE" ACTUAL_RELEASE_TAG=$(jq -r '.tag_name // empty' "$RELEASE_JSON_FILE") ACTUAL_TARGET_COMMITISH=$(jq -r '.target_commitish // empty' "$RELEASE_JSON_FILE") RELEASE_URL=$(jq -r '.html_url' "$RELEASE_JSON_FILE") jq -r '.body // ""' "$RELEASE_JSON_FILE" > "$ACTUAL_BODY_FILE" if [ "$ACTUAL_RELEASE_TAG" != "$TAG" ]; then echo "::error::Draft release ${RELEASE_ID} is bound to tag ${ACTUAL_RELEASE_TAG}, expected ${TAG}." exit 1 fi if [ "$ACTUAL_TARGET_COMMITISH" != "$HEAD_SHA" ]; then echo "::error::Draft release ${RELEASE_ID} target_commitish is ${ACTUAL_TARGET_COMMITISH}, expected ${HEAD_SHA}." exit 1 fi python3 scripts/release_control/render_release_body.py \ --version "${{ needs.prepare.outputs.version }}" \ --validate-body-file "$ACTUAL_BODY_FILE" \ --expected-body-file "$NOTES_FILE" rm -f "$NOTES_FILE" "$RELEASE_PAYLOAD" "$RELEASE_JSON_FILE" "$ACTUAL_BODY_FILE" echo "release_url=${RELEASE_URL}" >> $GITHUB_OUTPUT echo "release_id=${RELEASE_ID}" >> $GITHUB_OUTPUT echo "[OK] Draft release: ${TAG} (ID: ${RELEASE_ID})" - name: Upload checksums env: GH_TOKEN: ${{ github.token }} run: | TAG="${{ needs.prepare.outputs.tag }}" release_upload_with_retry() { local attempt=1 local max_attempts=5 local wait_seconds=15 while true; do if gh release upload "$@"; then return 0 fi if [ "$attempt" -ge "$max_attempts" ]; then echo "::error::gh release upload failed after ${max_attempts} attempts: $*" return 1 fi echo "gh release upload failed on attempt ${attempt}/${max_attempts}; retrying in ${wait_seconds}s: $*" sleep "$wait_seconds" attempt=$((attempt + 1)) if [ "$wait_seconds" -lt 120 ]; then wait_seconds=$((wait_seconds * 2)) if [ "$wait_seconds" -gt 120 ]; then wait_seconds=120 fi fi done } release_upload_with_retry "${TAG}" release/checksums.txt --clobber release_upload_with_retry "${TAG}" release/*.sha256 --clobber if ls release/*.sig 1> /dev/null 2>&1; then release_upload_with_retry "${TAG}" release/*.sig --clobber fi if ls release/*.sshsig 1> /dev/null 2>&1; then release_upload_with_retry "${TAG}" release/*.sshsig --clobber fi - name: Upload release assets env: GH_TOKEN: ${{ github.token }} run: | TAG="${{ needs.prepare.outputs.tag }}" release_upload_with_retry() { local attempt=1 local max_attempts=5 local wait_seconds=15 while true; do if gh release upload "$@"; then return 0 fi if [ "$attempt" -ge "$max_attempts" ]; then echo "::error::gh release upload failed after ${max_attempts} attempts: $*" return 1 fi echo "gh release upload failed on attempt ${attempt}/${max_attempts}; retrying in ${wait_seconds}s: $*" sleep "$wait_seconds" attempt=$((attempt + 1)) if [ "$wait_seconds" -lt 120 ]; then wait_seconds=$((wait_seconds * 2)) if [ "$wait_seconds" -gt 120 ]; then wait_seconds=120 fi fi done } if ls release/*.sbom.spdx.json 1> /dev/null 2>&1; then release_upload_with_retry "${TAG}" release/*.sbom.spdx.json --clobber fi release_upload_with_retry "${TAG}" release/*.tar.gz --clobber release_upload_with_retry "${TAG}" release/*.zip --clobber if ls release/*.tgz 1> /dev/null 2>&1; then release_upload_with_retry "${TAG}" release/*.tgz --clobber fi for bare_agent in \ release/pulse-agent-linux-amd64 \ release/pulse-agent-linux-arm64 \ release/pulse-agent-linux-armv7 \ release/pulse-agent-linux-armv6 \ release/pulse-agent-linux-386 \ release/pulse-agent-freebsd-amd64 \ release/pulse-agent-freebsd-arm64 \ release/pulse-agent-windows-amd64.exe \ release/pulse-agent-windows-arm64.exe \ release/pulse-agent-windows-386.exe; do if [ -f "${bare_agent}" ]; then release_upload_with_retry "${TAG}" "${bare_agent}" --clobber fi done for bare_mcp in \ release/pulse-mcp-linux-amd64 \ release/pulse-mcp-linux-arm64 \ release/pulse-mcp-linux-armv7 \ release/pulse-mcp-linux-armv6 \ release/pulse-mcp-linux-386 \ release/pulse-mcp-darwin-amd64 \ release/pulse-mcp-darwin-arm64 \ release/pulse-mcp-freebsd-amd64 \ release/pulse-mcp-freebsd-arm64 \ release/pulse-mcp-windows-amd64.exe \ release/pulse-mcp-windows-arm64.exe \ release/pulse-mcp-windows-386.exe; do if [ -f "${bare_mcp}" ]; then release_upload_with_retry "${TAG}" "${bare_mcp}" --clobber fi done release_upload_with_retry "${TAG}" release/install.sh --clobber if [ -f release/install.ps1 ]; then release_upload_with_retry "${TAG}" release/install.ps1 --clobber fi if [ -f release/install-mcp.sh ]; then release_upload_with_retry "${TAG}" release/install-mcp.sh --clobber fi if [ -f release/install-mcp.ps1 ]; then release_upload_with_retry "${TAG}" release/install-mcp.ps1 --clobber fi release_upload_with_retry "${TAG}" release/install-docker.sh --clobber release_upload_with_retry "${TAG}" release/pulse-auto-update.sh --clobber - name: Publish release if: ${{ github.event.inputs.draft_only != 'true' }} env: GH_TOKEN: ${{ github.token }} run: | TAG="${{ needs.prepare.outputs.tag }}" RELEASE_ID="${{ steps.create_release.outputs.release_id }}" IS_PRERELEASE="${{ needs.prepare.outputs.is_prerelease }}" if [ "$IS_PRERELEASE" = "true" ]; then gh api "repos/${{ github.repository }}/releases/${RELEASE_ID}" \ -X PATCH -F draft=false -F make_latest=false echo "[OK] Published as prerelease: ${TAG}" else # 'latest' belongs to the highest stable semver overall. A # maintenance cut of an older line (e.g. v5.1.36 after v6 GA) # publishes without stealing the latest marker from the current # line. 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 gh api "repos/${{ github.repository }}/releases/${RELEASE_ID}" \ -X PATCH -F draft=false -F make_latest=true echo "[OK] Published as latest: ${TAG}" else gh api "repos/${{ github.repository }}/releases/${RELEASE_ID}" \ -X PATCH -F draft=false -F make_latest=false echo "[OK] Published WITHOUT latest marker: ${TAG} (highest stable is ${HIGHEST_STABLE})" fi fi - name: Skip publish (draft only) if: ${{ github.event.inputs.draft_only == 'true' }} run: 'echo "Draft-only mode: ${{ steps.create_release.outputs.release_url }}"' - name: Summary run: | echo "[SUCCESS] Release published!" echo "Release: ${{ needs.prepare.outputs.tag }}" echo "URL: ${{ steps.create_release.outputs.release_url }}" backfill_release_assets: needs: - prepare if: ${{ needs.prepare.outputs.historical_asset_backfill_only == 'true' }} runs-on: ubuntu-24.04 timeout-minutes: 30 permissions: contents: write steps: - name: Checkout repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Set up Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: go.mod cache: true - name: Install Syft run: | set -euo pipefail SYFT_VERSION="1.42.4" SYFT_ARCHIVE="syft_${SYFT_VERSION}_linux_amd64.tar.gz" SYFT_SHA256="590650c2743b83f327d1bf9bec64f6f83b7fec504187bb84f500c862bf8f2a0f" TMP_DIR="$(mktemp -d)" trap 'rm -rf "$TMP_DIR"' EXIT curl -fsSL "https://github.com/anchore/syft/releases/download/v${SYFT_VERSION}/${SYFT_ARCHIVE}" \ -o "${TMP_DIR}/${SYFT_ARCHIVE}" printf '%s %s\n' "${SYFT_SHA256}" "${TMP_DIR}/${SYFT_ARCHIVE}" | sha256sum --check -- tar -xzf "${TMP_DIR}/${SYFT_ARCHIVE}" -C "${TMP_DIR}" syft install -m 0755 "${TMP_DIR}/syft" /usr/local/bin/syft syft version - name: Backfill published release assets env: GH_TOKEN: ${{ github.token }} PULSE_UPDATE_SIGNING_KEY: ${{ secrets.PULSE_UPDATE_SIGNING_KEY }} PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }} run: | ./scripts/backfill-release-assets.sh --tag "${{ needs.prepare.outputs.tag }}" --repo "${{ github.repository }}" - name: Validate published release packet run: | ./scripts/validate-published-release.sh "${{ needs.prepare.outputs.tag }}" "${{ github.repository }}" - name: Summary run: | echo "[SUCCESS] Historical release assets repaired" echo "Release: ${{ needs.prepare.outputs.tag }}" publish_docker: needs: - prepare - create_release if: ${{ always() && needs.prepare.result == 'success' && needs.create_release.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' && github.event.inputs.draft_only != 'true' }} permissions: contents: read packages: write id-token: write attestations: write uses: ./.github/workflows/publish-docker.yml secrets: inherit with: tag: ${{ needs.prepare.outputs.tag }} validate_release_assets: needs: - prepare - build_release_candidate - create_release if: ${{ always() && needs.prepare.result == 'success' && needs.build_release_candidate.result == 'success' && needs.create_release.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' }} permissions: contents: write issues: write statuses: write uses: ./.github/workflows/validate-release-assets.yml secrets: inherit with: tag: ${{ needs.prepare.outputs.tag }} version: ${{ needs.prepare.outputs.version }} release_id: ${{ needs.create_release.outputs.release_id }} draft: ${{ github.event.inputs.draft_only == '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 just-published release. Catches # runtime regressions in the documented Proxmox-LXC / systemd install flow # that the build-time validate-release.sh checks cannot see: the script # parses fine, signs cleanly, but fails to actually install or boot Pulse. # This class of regression broke silently across v6 rc.1 → rc.5 because no # existing gate exercised the documented secure-install commands against # the published GitHub Release URL. # # Gated on validate_release_assets success — the smoke depends on the # published asset bundle being well-formed, so we only run it after the # cheaper content checks pass. Skipped for the historical-backfill path # since that flow re-uploads to an already-published release and the # smoke would just re-confirm what hasn't changed. Also skipped for # draft-only runs because draft release assets are not available at the # public /releases/download// URL this smoke intentionally exercises. install_sh_smoke: needs: - prepare - validate_release_assets if: ${{ always() && needs.prepare.result == 'success' && needs.validate_release_assets.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' && github.event.inputs.draft_only != 'true' }} permissions: contents: read uses: ./.github/workflows/install-sh-smoke.yml secrets: inherit with: tag: ${{ needs.prepare.outputs.tag }} version: ${{ needs.prepare.outputs.version }} repository: ${{ github.repository }} update_stable_demo: 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' && needs.prepare.outputs.is_prerelease != 'true' && startsWith(needs.prepare.outputs.version, '6.') }} permissions: contents: read uses: ./.github/workflows/update-demo-server.yml secrets: inherit with: tag: ${{ needs.prepare.outputs.tag }} target: stable verify_only: false # Publish the Helm chart for this release. publish-helm-chart.yml also # listens for `release: published` events directly, but the create_release # publish step PATCHes a draft release to draft=false rather than creating # it as draft=false from the start — that GitHub-documented path does NOT # fire `release: published`. Across v6 rc.1 → rc.5 the release-event branch # never triggered helm publish, leaving rcourtman.github.io/Pulse/index.yaml # without any v6 chart and breaking `helm install pulse pulse/pulse # --version 6.0.0-rc.5`. Calling the workflow explicitly here is the # canonical fix. Draft-only runs must not publish the chart because the # release has not crossed the operator-controlled publication boundary. publish_helm_chart: needs: - prepare - validate_release_assets if: ${{ always() && needs.prepare.result == 'success' && needs.validate_release_assets.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' && github.event.inputs.draft_only != 'true' }} permissions: contents: write packages: write uses: ./.github/workflows/publish-helm-chart.yml secrets: inherit with: chart_version: ${{ needs.prepare.outputs.version }} app_version: ${{ needs.prepare.outputs.version }} # Defensive backup to promote-floating-tags.yml's workflow_run chain off # publish-docker.yml. The chain works when publish-docker succeeds, but # when it fails the floating tags don't advance and customers pulling # rcourtman/pulse:latest stay on whatever the previous successful release # tagged. Calling promote-floating-tags as workflow_call after # validate_release_assets and publish_docker succeed guarantees the floating # tags advance. Draft-only runs must not promote floating tags because the # release is still in private promotion state. promote_floating_tags: needs: - prepare - publish_docker - validate_release_assets if: ${{ always() && needs.prepare.result == 'success' && needs.publish_docker.result == 'success' && needs.validate_release_assets.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' && github.event.inputs.draft_only != 'true' }} permissions: contents: read packages: write uses: ./.github/workflows/promote-floating-tags.yml secrets: inherit with: tag: ${{ needs.prepare.outputs.tag }} prerelease: ${{ needs.prepare.outputs.is_prerelease == 'true' }} # Customer-facing v6 public releases must not outrun the private Pulse Pro # runtime path. The public release is the immutable source tag; this job # dispatches the private build against that exact tag, waits for the R2 and # Docker publication workflow to pass, then dispatches the pulse-pro live # promotion workflow and waits for the signed packet to update the license # broker. A failure here fails the release pipeline instead of leaving paid # customers on a stale private manifest. publish_private_pro_runtime: needs: - prepare - create_release if: ${{ always() && needs.prepare.result == 'success' && needs.create_release.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' && github.event.inputs.draft_only != 'true' && startsWith(needs.prepare.outputs.version, '6.') }} runs-on: ubuntu-24.04 timeout-minutes: 150 steps: - name: Dispatch and verify private Pro runtime publication 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 workflow="$2" local branch="$3" local started_at="$4" local label="$5" local timeout_seconds="$6" local deadline=$((SECONDS + timeout_seconds)) local run_id="" while (( SECONDS < deadline )); do if [[ -z "${run_id}" ]]; then run_id="$( gh run list \ --repo "${repo}" \ --workflow "${workflow}" \ --event workflow_dispatch \ --branch "${branch}" \ --limit 50 \ --json databaseId,createdAt \ --jq "map(select(.createdAt >= \"${started_at}\")) | sort_by(.createdAt) | reverse | .[0].databaseId // \"\"" )" if [[ -n "${run_id}" ]]; then echo "Watching ${label} run ${run_id} in ${repo}." else echo "Waiting for ${label} workflow run to appear..." fi fi if [[ -n "${run_id}" ]]; then 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 fi sleep 30 done echo "::error::Timed out waiting for ${label} after ${timeout_seconds}s." return 1 } allow_ga_publish=false if [[ "${IS_PRERELEASE}" != "true" ]]; then allow_ga_publish=true fi r2_prefix="${TAG}-pro-$(date -u '+%Y%m%d')-${GITHUB_RUN_ID}" build_started_at="$(date -u '+%Y-%m-%dT%H:%M:%SZ')" echo "Dispatching private Pro build for ${TAG} with R2 prefix ${r2_prefix}." gh workflow run build-pro-release.yml \ --repo rcourtman/pulse-enterprise \ --ref main \ -f pulse_ref="${TAG}" \ -f version="${VERSION}" \ -f upload_actions_artifact=false \ -f upload_to_r2=true \ -f publish_docker_image=true \ -f docker_image=license.pulserelay.pro/pulse-pro \ -f r2_prefix="${r2_prefix}" \ -f allow_stable_ga_publish="${allow_ga_publish}" wait_for_workflow rcourtman/pulse-enterprise "Build Pro Release" main "${build_started_at}" "private Pro build" 7200 promote_started_at="$(date -u '+%Y-%m-%dT%H:%M:%SZ')" echo "Dispatching live paid-runtime promotion for ${TAG} with R2 prefix ${r2_prefix}." gh workflow run promote-paid-runtime-release.yml \ --repo rcourtman/pulse-pro \ --ref main \ -f version="${VERSION}" \ -f r2_prefix="${r2_prefix}" \ -f allow_ga_prefix="${allow_ga_publish}" wait_for_workflow rcourtman/pulse-pro "Promote Paid Runtime Release" main "${promote_started_at}" "private Pro live promotion" 3600 release_verdict: name: Definitive Release Verdict needs: - prepare - create_release - publish_docker - validate_release_assets - install_sh_smoke - update_stable_demo - publish_helm_chart - promote_floating_tags - publish_private_pro_runtime if: ${{ always() && needs.prepare.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' }} runs-on: ubuntu-24.04 steps: - name: Enforce terminal release outcomes env: DRAFT_ONLY: ${{ github.event.inputs.draft_only }} VERSION: ${{ needs.prepare.outputs.version }} IS_PRERELEASE: ${{ needs.prepare.outputs.is_prerelease }} CREATE_RESULT: ${{ needs.create_release.result }} DOCKER_RESULT: ${{ needs.publish_docker.result }} VALIDATE_RESULT: ${{ needs.validate_release_assets.result }} INSTALL_RESULT: ${{ needs.install_sh_smoke.result }} DEMO_RESULT: ${{ needs.update_stable_demo.result }} HELM_RESULT: ${{ needs.publish_helm_chart.result }} FLOATING_RESULT: ${{ needs.promote_floating_tags.result }} PRIVATE_PRO_RESULT: ${{ needs.publish_private_pro_runtime.result }} run: | set -euo pipefail require_result() { local name="$1" local actual="$2" local expected="$3" if [ "$actual" != "$expected" ]; then echo "::error::${name} ended as ${actual}; expected ${expected}." return 1 fi } require_result "release assembly" "$CREATE_RESULT" success require_result "release asset validation" "$VALIDATE_RESULT" success if [ "${DRAFT_ONLY:-false}" != "true" ]; then require_result "Docker publication" "$DOCKER_RESULT" success require_result "install.sh smoke" "$INSTALL_RESULT" success require_result "Helm publication" "$HELM_RESULT" success require_result "floating-tag promotion" "$FLOATING_RESULT" success if [[ "$VERSION" == 6.* ]]; then require_result "private Pro publication" "$PRIVATE_PRO_RESULT" success if [ "$IS_PRERELEASE" != "true" ]; then require_result "stable demo deployment and verification" "$DEMO_RESULT" success fi fi fi echo "Release verdict passed for v${VERSION}."