name: Pulse Release Pipeline 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 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 jobs: extract_version: runs-on: ubuntu-latest timeout-minutes: 5 outputs: version: ${{ steps.extract.outputs.version }} tag: ${{ steps.extract.outputs.tag }} is_prerelease: ${{ steps.extract.outputs.is_prerelease }} steps: - name: Extract version id: extract run: | # Handle both tag push and workflow_dispatch if [ "${{ github.event_name }}" = "push" ]; then TAG="${GITHUB_REF#refs/tags/}" VERSION="${TAG#v}" else 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}" fi # Detect if this is a prerelease (RC, alpha, beta) 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 echo "tag=${TAG}" >> $GITHUB_OUTPUT echo "version=${VERSION}" >> $GITHUB_OUTPUT echo "is_prerelease=${IS_PRERELEASE}" >> $GITHUB_OUTPUT echo "Version: ${VERSION}, Tag: ${TAG}, Prerelease: ${IS_PRERELEASE}" version_guard: needs: extract_version runs-on: ubuntu-latest timeout-minutes: 10 steps: - name: Checkout repository uses: actions/checkout@v4 - name: Ensure VERSION file matches requested version run: | FILE_VERSION=$(cat VERSION | tr -d '\n') REQUESTED_VERSION="${{ needs.extract_version.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 "✓ VERSION file matches requested version ($REQUESTED_VERSION)" preflight_tests: needs: - extract_version - version_guard runs-on: ubuntu-latest timeout-minutes: 90 permissions: contents: read packages: write env: FRONTEND_DIST: frontend-modern/dist steps: - name: Checkout repository uses: actions/checkout@v4 - name: Set up Node.js uses: actions/setup-node@v4 with: node-version: '20' cache: 'npm' cache-dependency-path: 'frontend-modern/package-lock.json' - name: Install frontend dependencies run: npm --prefix frontend-modern ci - name: Restore frontend build cache uses: actions/cache@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 bundle for Go embed run: | if [ -d "$FRONTEND_DIST" ] && [ -f "$FRONTEND_DIST/index.html" ]; then echo "Using cached frontend build"; else npm --prefix frontend-modern run build; fi rm -rf internal/api/frontend-modern mkdir -p internal/api/frontend-modern cp -r frontend-modern/dist internal/api/frontend-modern/ - name: Lint frontend run: npm --prefix frontend-modern run lint - name: Install docker-compose run: | sudo apt-get update sudo apt-get install -y docker-compose - name: Set up Go uses: actions/setup-go@v5 with: go-version: '1.24' cache: true - name: Run backend tests run: make test - name: Cache Playwright browsers uses: actions/cache@v4 with: path: ~/.cache/ms-playwright key: playwright-${{ runner.os }}-${{ hashFiles('tests/integration/package-lock.json') }} - name: Prepare integration test dependencies working-directory: tests/integration run: | npm ci npx playwright install --with-deps chromium - name: Build Pulse for integration tests run: make build - name: Set up QEMU uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Log in to Docker Hub uses: docker/login-action@v3 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Log in to GHCR uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push staging Docker images uses: docker/build-push-action@v6 with: context: . target: runtime platforms: linux/amd64 # amd64-only for faster preflight; multi-arch happens in release job push: true provenance: false 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: | PULSE_LICENSE_PUBLIC_KEY=${{ secrets.PULSE_LICENSE_PUBLIC_KEY }} tags: | ghcr.io/${{ github.repository_owner }}/pulse:staging-${{ needs.extract_version.outputs.tag }} - name: Build and push staging Docker agent image uses: docker/build-push-action@v6 with: context: . file: ./Dockerfile target: agent_runtime platforms: linux/amd64 # amd64-only for faster preflight; multi-arch happens in release job push: true provenance: false cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/pulse-docker-agent:buildcache cache-to: type=registry,ref=ghcr.io/${{ github.repository_owner }}/pulse-docker-agent:buildcache,mode=max build-args: | PULSE_LICENSE_PUBLIC_KEY=${{ secrets.PULSE_LICENSE_PUBLIC_KEY }} tags: | ghcr.io/${{ github.repository_owner }}/pulse-docker-agent:staging-${{ needs.extract_version.outputs.tag }} - name: Build Docker images for integration tests run: | docker build -t pulse-mock-github:test tests/integration/mock-github-server docker build -t pulse:test -f Dockerfile --target runtime --cache-from ghcr.io/${{ github.repository_owner }}/pulse:buildcache --build-arg BUILDKIT_INLINE_CACHE=1 --build-arg PULSE_LICENSE_PUBLIC_KEY="$PULSE_LICENSE_PUBLIC_KEY" . env: PULSE_LICENSE_PUBLIC_KEY: ${{ secrets.PULSE_LICENSE_PUBLIC_KEY }} - name: Run update integration smoke tests 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 # Wait for services to be healthy echo "Waiting for mock-github 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' || { echo "Mock GitHub failed to become healthy" docker logs pulse-mock-github exit 1 } echo "Waiting for pulse-test-server to be healthy..." timeout 60 sh -c 'until docker inspect --format="{{json .State.Health.Status}}" pulse-test-server | grep -q "healthy"; do sleep 2; done' || { echo "Pulse server failed to become healthy" docker logs pulse-test-server exit 1 } echo "All services healthy, verifying port mapping..." # Test that the host can actually reach the container through port mapping for i in 1 2 3 4 5; do if curl -f -s http://localhost:7655/api/health > /dev/null 2>&1; then echo "Port mapping verified: Pulse server is reachable from host" break elif [ $i -eq 5 ]; then echo "ERROR: Port mapping failed - cannot reach Pulse server from host" echo "Container healthcheck passed, but host cannot connect via localhost:7655" echo "Pulse server logs:" docker logs pulse-test-server || true echo "Mock GitHub logs:" docker logs pulse-mock-github || true exit 1 else echo "Attempt $i: Server not yet reachable from host, waiting..." sleep 2 fi done echo "Running API-level update integration test..." UPDATE_API_BASE_URL=http://localhost:7655 go test ../../tests/integration/api -run TestUpdateFlowIntegration -count=1 echo "Skipping legacy Playwright update scenarios (removed until they can be rebuilt)" docker-compose -f docker-compose.test.yml down -v - name: Cleanup integration environment if: always() working-directory: tests/integration run: docker-compose -f docker-compose.test.yml down -v || true create_release: needs: - extract_version - preflight_tests runs-on: ubuntu-latest 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@v4 with: fetch-depth: 0 # Fetch all history for changelog generation - name: Set up Go uses: actions/setup-go@v5 with: go-version: '1.24' cache: true - name: Set up Node.js uses: actions/setup-node@v4 with: node-version: '20' cache: 'npm' cache-dependency-path: 'frontend-modern/package-lock.json' - name: Install dependencies run: | # Install zip for Windows binaries sudo apt-get update sudo apt-get install -y zip - name: Set up Helm uses: azure/setup-helm@v4 with: version: 'v3.15.2' - name: Build release artifacts run: | echo "Building release ${{ needs.extract_version.outputs.tag }}..." ./scripts/build-release.sh ${{ needs.extract_version.outputs.version }} env: PULSE_LICENSE_PUBLIC_KEY: ${{ secrets.PULSE_LICENSE_PUBLIC_KEY }} - name: Post-build health check run: | echo "Verifying server binary responds to --version and API health..." if [ -x ./pulse ]; then ./pulse --version elif [ -x ./cmd/pulse/pulse ]; then ./cmd/pulse/pulse --version else echo "::warning::Pulse binary not found after build-release; skipping version check" fi - name: Prepare release notes id: generate_notes run: | VERSION="${{ needs.extract_version.outputs.version }}" RELEASE_NOTES_INPUT=$(jq -r '.inputs.release_notes // ""' "$GITHUB_EVENT_PATH" 2>/dev/null || echo "") # Save release notes to file NOTES_FILE=$(mktemp) if [ -n "$RELEASE_NOTES_INPUT" ]; then echo "Using Claude-generated release notes from workflow input" printf "%s\n" "$RELEASE_NOTES_INPUT" > "$NOTES_FILE" else echo "Tag-triggered release - using placeholder notes" echo "Release $VERSION" > "$NOTES_FILE" echo "" >> "$NOTES_FILE" echo "See commit history for changes." >> "$NOTES_FILE" fi # Add installation instructions { echo "" echo "## Installation" echo "" echo "**Docker (recommended):**" echo '```bash' echo "docker pull rcourtman/pulse:${VERSION}" echo '```' echo "" echo "**Docker Compose:**" echo "Update your \`docker-compose.yml\` to use \`rcourtman/pulse:${VERSION}\`" echo "" echo "See the [Installation Guide](https://github.com/rcourtman/Pulse#installation) for complete setup instructions." } >> "$NOTES_FILE" echo "notes_file=${NOTES_FILE}" >> $GITHUB_OUTPUT echo "Release notes content:" cat "$NOTES_FILE" - name: Create tag env: GH_TOKEN: ${{ github.token }} run: | TAG="${{ needs.extract_version.outputs.tag }}" 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}" - name: Create draft release id: create_release env: GH_TOKEN: ${{ github.token }} run: | TAG="${{ needs.extract_version.outputs.tag }}" NOTES_FILE="${{ steps.generate_notes.outputs.notes_file }}" echo "Creating draft release for ${TAG}..." # Tag must exist first - draft releases can't create tags (GitHub API limitation) # See: https://github.com/cli/cli/issues/11589 # Create as draft first so we can upload assets before publishing IS_PRERELEASE="${{ needs.extract_version.outputs.is_prerelease }}" RELEASE_JSON=$(gh api "repos/${{ github.repository }}/releases" \ -X POST \ -F tag_name="${TAG}" \ -F name="Pulse ${TAG}" \ -F body="$(cat $NOTES_FILE)" \ -F draft=true \ -F prerelease=${IS_PRERELEASE}) rm -f "$NOTES_FILE" RELEASE_ID=$(echo "$RELEASE_JSON" | jq -r '.id') RELEASE_URL=$(echo "$RELEASE_JSON" | jq -r '.html_url') if [ -z "$RELEASE_ID" ] || [ "$RELEASE_ID" = "null" ]; then echo "::error::Failed to extract release ID from API response" exit 1 fi echo "release_url=${RELEASE_URL}" >> $GITHUB_OUTPUT echo "release_id=${RELEASE_ID}" >> $GITHUB_OUTPUT echo "✓ Draft release created: ${TAG} (ID: ${RELEASE_ID})" - name: Upload checksums.txt env: GH_TOKEN: ${{ github.token }} run: | TAG="${{ needs.extract_version.outputs.tag }}" echo "Uploading checksums.txt..." gh release upload "${TAG}" release/checksums.txt # Upload individual .sha256 files for backward compatibility echo "Uploading .sha256 checksum files..." gh release upload "${TAG}" release/*.sha256 - name: Upload release assets env: GH_TOKEN: ${{ github.token }} run: | TAG="${{ needs.extract_version.outputs.tag }}" echo "Uploading release assets..." # Upload tarballs gh release upload "${TAG}" release/*.tar.gz # Upload Windows zip files gh release upload "${TAG}" release/*.zip # Upload Helm chart if it exists if ls release/*.tgz 1> /dev/null 2>&1; then echo "Uploading Helm chart..." gh release upload "${TAG}" release/*.tgz fi # Upload install scripts as standalone assets # Users can now use: curl -fsSL https://github.com/rcourtman/Pulse/releases/latest/download/install.sh | bash # This ensures scripts are version-locked to the release, not pulled from main branch gh release upload "${TAG}" release/install.sh gh release upload "${TAG}" release/install-sensor-proxy.sh gh release upload "${TAG}" release/install-docker.sh gh release upload "${TAG}" release/pulse-auto-update.sh - name: Publish release if: ${{ github.event.inputs.draft_only != 'true' }} env: GH_TOKEN: ${{ github.token }} run: | TAG="${{ needs.extract_version.outputs.tag }}" RELEASE_ID="${{ steps.create_release.outputs.release_id }}" IS_PRERELEASE="${{ needs.extract_version.outputs.is_prerelease }}" echo "Publishing release ${TAG} (prerelease: ${IS_PRERELEASE})..." # Only mark as latest if this is NOT a prerelease if [ "$IS_PRERELEASE" = "true" ]; then gh api "repos/${{ github.repository }}/releases/${RELEASE_ID}" \ -X PATCH \ -F draft=false \ -F make_latest=false echo "✓ Release published as prerelease: ${TAG}" else gh api "repos/${{ github.repository }}/releases/${RELEASE_ID}" \ -X PATCH \ -F draft=false \ -F make_latest=true echo "✓ Release published as latest: ${TAG}" fi - name: Skip publish (draft only) if: ${{ github.event.inputs.draft_only == 'true' }} run: | echo "Draft-only mode: Release remains as draft for review" echo "View draft at: ${{ steps.create_release.outputs.release_url }}" - name: Trigger Docker image publish if: ${{ github.event.inputs.draft_only != 'true' }} continue-on-error: true # Non-fatal if dispatch fails env: GH_TOKEN: ${{ secrets.WORKFLOW_PAT }} run: | TAG="${{ needs.extract_version.outputs.tag }}" echo "Triggering Docker image publish for ${TAG}..." # Publishing via API doesn't fire the release webhook, so we dispatch manually # Requires WORKFLOW_PAT secret with 'repo' and 'workflow' scopes gh workflow run publish-docker.yml -f tag="${TAG}" echo "✓ Docker publish workflow dispatched" # NOTE: Floating tag promotion and Helm chart release workflows now trigger # automatically when publish-docker.yml completes via workflow_run. # No need to dispatch them manually - this eliminates race conditions. - name: Trigger demo server update if: ${{ github.event.inputs.draft_only != 'true' }} continue-on-error: true # Non-fatal if dispatch fails env: GH_TOKEN: ${{ secrets.WORKFLOW_PAT }} run: | TAG="${{ needs.extract_version.outputs.tag }}" echo "Triggering demo server update for ${TAG}..." gh workflow run update-demo-server.yml -f tag="${TAG}" echo "✓ Demo server update workflow dispatched" - name: Output release information run: | echo "✅ Release published successfully!" echo "" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "📦 Release: ${{ needs.extract_version.outputs.tag }}" echo "🔗 URL: ${{ steps.create_release.outputs.release_url }}" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "" echo "Docker images, Helm chart, and demo server will be updated automatically." echo "" validate_release_assets: needs: - extract_version - create_release uses: ./.github/workflows/validate-release-assets.yml secrets: inherit with: tag: ${{ needs.extract_version.outputs.tag }} version: ${{ needs.extract_version.outputs.version }} release_id: ${{ needs.create_release.outputs.release_id }} draft: false target_commitish: ${{ needs.create_release.outputs.target_commitish }}