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) - generated by Claude' 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 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 concurrency: group: release-${{ github.event.inputs.version || github.ref || github.run_id }} cancel-in-progress: false permissions: 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 }} 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@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: fetch-depth: 0 sparse-checkout: | VERSION docs/release-control/control_plane.json scripts/release_control/control_plane.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 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 }} run: | set -euo pipefail git fetch --prune origin main "${REQUIRED_BRANCH}" --tags RELEASE_NOTES_INPUT="$(jq -r '.inputs.release_notes // ""' "$GITHUB_EVENT_PATH")" NOTES_FILE="$(mktemp)" printf '%s\n' "$RELEASE_NOTES_INPUT" > "$NOTES_FILE" 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 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}" # 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@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Set up Node.js uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 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 # Backend tests run in parallel with frontend checks backend_tests: needs: prepare 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@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Set up Node.js uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: '20' cache: 'npm' cache-dependency-path: 'frontend-modern/package-lock.json' - name: Restore frontend build cache id: frontend-cache uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: path: frontend-modern/dist key: frontend-build-${{ hashFiles('frontend-modern/package-lock.json', 'frontend-modern/src/**/*', 'frontend-modern/index.html', 'frontend-modern/postcss.config.cjs', 'frontend-modern/tailwind.config.cjs') }} - name: Build frontend (if not cached) if: steps.frontend-cache.outputs.cache-hit != 'true' run: | npm --prefix frontend-modern ci npm --prefix frontend-modern run build - 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@40f1582b2485089dde7abd97c1529aa768e1baff # v5 with: go-version: '1.25.9' 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@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Set up QEMU if: needs.prepare.outputs.is_prerelease != 'true' uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3 - name: Set up Docker Buildx uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - name: Log in to GHCR uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 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@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 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@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 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@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Set up Helm uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4 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 - backend_tests 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@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Set up Node.js uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: '20' cache: 'npm' cache-dependency-path: 'frontend-modern/package-lock.json' - name: Restore frontend build cache uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: path: frontend-modern/dist key: frontend-build-${{ hashFiles('frontend-modern/package-lock.json', 'frontend-modern/src/**/*', 'frontend-modern/index.html', 'frontend-modern/postcss.config.cjs', 'frontend-modern/tailwind.config.cjs') }} - 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@40f1582b2485089dde7abd97c1529aa768e1baff # v5 with: go-version: '1.25.9' 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 "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 multi-tenant E2E suite..." npx playwright test tests/03-multi-tenant.spec.ts --project=chromium --reporter=list docker compose -f docker-compose.test.yml down -v - 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 - 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.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@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: fetch-depth: 0 - name: Set up Go uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 with: go-version: '1.25.9' cache: true - name: Set up Node.js uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: '20' cache: 'npm' cache-dependency-path: 'frontend-modern/package-lock.json' - name: Install dependencies run: | sudo apt-get update sudo apt-get install -y zip - name: Set up Helm uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4 with: version: 'v3.15.2' - 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: Build release artifacts run: | echo "Building release ${{ needs.prepare.outputs.tag }}..." ./scripts/build-release.sh ${{ needs.prepare.outputs.version }} env: 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 }} - name: Validate installer signing key pins env: PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }} run: | set -euo pipefail TRUSTED_SSH_PUBLIC_KEY="$( go run ./scripts/release_update_key.go public-key-ssh \ --public-key "${PULSE_UPDATE_SIGNING_PUBLIC_KEY}" \ --comment pulse-installer )" for installer in install.sh scripts/pulse-auto-update.sh release/pulse-auto-update.sh; do grep -F "PINNED_RELEASE_SSH_PUBLIC_KEY=\"${TRUSTED_SSH_PUBLIC_KEY}\"" "${installer}" >/dev/null || { echo "::error::${installer} does not trust the configured release signing key." exit 1 } done - name: Post-build health check run: | if [ -x ./pulse ]; then ./pulse --version elif [ -x ./cmd/pulse/pulse ]; then ./cmd/pulse/pulse --version fi - name: Attest release assets uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4 with: subject-path: release/* - name: Prepare release notes id: generate_notes run: | VERSION="${{ needs.prepare.outputs.version }}" RELEASE_NOTES_INPUT=$(jq -r '.inputs.release_notes // ""' "$GITHUB_EVENT_PATH" 2>/dev/null || echo "") NOTES_FILE=$(mktemp) if [ -n "$RELEASE_NOTES_INPUT" ]; then printf "%s\n" "$RELEASE_NOTES_INPUT" > "$NOTES_FILE" else echo "# Pulse v${VERSION} Release Notes" > "$NOTES_FILE" echo "" >> "$NOTES_FILE" echo "See commit history for changes." >> "$NOTES_FILE" 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 }}" 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: | 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 }}" 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 \ -F tag_name="${TAG}" \ -F target_commitish="${HEAD_SHA}" \ -F name="Pulse ${TAG}" \ -F body="$(cat "$NOTES_FILE")" \ -F draft=true \ -F prerelease=${IS_PRERELEASE} > /dev/null else echo "::error::Published release already exists for ${TAG}." exit 1 fi else echo "Creating draft release for ${TAG}..." RELEASE_JSON=$(gh api "repos/${{ github.repository }}/releases" \ -X POST \ -F tag_name="${TAG}" \ -F target_commitish="${HEAD_SHA}" \ -F name="Pulse ${TAG}" \ -F body="$(cat "$NOTES_FILE")" \ -F draft=true \ -F prerelease=${IS_PRERELEASE}) RELEASE_ID=$(echo "$RELEASE_JSON" | jq -r '.id') RELEASE_URL=$(echo "$RELEASE_JSON" | jq -r '.html_url') fi RELEASE_JSON=$(gh api "repos/${{ github.repository }}/releases/${RELEASE_ID}") ACTUAL_RELEASE_TAG=$(echo "$RELEASE_JSON" | jq -r '.tag_name // empty') ACTUAL_TARGET_COMMITISH=$(echo "$RELEASE_JSON" | jq -r '.target_commitish // empty') RELEASE_URL=$(echo "$RELEASE_JSON" | jq -r '.html_url') 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 rm -f "$NOTES_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 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 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 gh api "repos/${{ github.repository }}/releases/${RELEASE_ID}" \ -X PATCH -F draft=false -F make_latest=true echo "[OK] Published as latest: ${TAG}" 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: Trigger Docker image publish if: ${{ github.event.inputs.draft_only != 'true' }} continue-on-error: true env: GH_TOKEN: ${{ secrets.WORKFLOW_PAT }} REQUIRED_BRANCH: ${{ needs.prepare.outputs.required_branch }} run: | gh workflow run publish-docker.yml --ref "${REQUIRED_BRANCH}" -f tag="${{ needs.prepare.outputs.tag }}" echo "[OK] Docker publish workflow dispatched from ${REQUIRED_BRANCH}" - name: Trigger demo server update if: ${{ github.event.inputs.draft_only != 'true' }} continue-on-error: true env: GH_TOKEN: ${{ secrets.WORKFLOW_PAT }} REQUIRED_BRANCH: ${{ needs.prepare.outputs.required_branch }} run: | if [ "${{ needs.prepare.outputs.is_prerelease }}" = "true" ]; then TARGET="preview-v6" else TARGET="stable" fi gh workflow run update-demo-server.yml --ref "${REQUIRED_BRANCH}" -f tag="${{ needs.prepare.outputs.tag }}" -f target="${TARGET}" echo "[OK] Demo server update dispatched for ${TARGET} from ${REQUIRED_BRANCH}" - 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@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Set up Go uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 with: go-version: '1.25.9' 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 }}" validate_release_assets: needs: - prepare - create_release if: ${{ always() && needs.prepare.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 }}