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 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 }} 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 echo "tag=${TAG}" >> $GITHUB_OUTPUT echo "version=${VERSION}" >> $GITHUB_OUTPUT echo "Version: ${VERSION}, Tag: ${TAG}" 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: version_guard runs-on: ubuntu-latest timeout-minutes: 90 permissions: contents: read packages: read 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: Log in to GHCR for build cache env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | if [ -z "${GH_TOKEN:-}" ]; then echo "::error::GITHUB_TOKEN not available for GHCR login" exit 1 fi echo "$GH_TOKEN" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin - 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 . - 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 build_docker_images: needs: - extract_version - version_guard - preflight_tests runs-on: ubuntu-latest timeout-minutes: 60 permissions: contents: read packages: write steps: - name: Checkout repository uses: actions/checkout@v4 - 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 Pulse server image uses: docker/build-push-action@v6 with: context: . target: runtime platforms: linux/amd64,linux/arm64 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 tags: | rcourtman/pulse:${{ needs.extract_version.outputs.tag }} rcourtman/pulse:${{ needs.extract_version.outputs.version }} rcourtman/pulse:latest ghcr.io/${{ github.repository_owner }}/pulse:${{ needs.extract_version.outputs.tag }} ghcr.io/${{ github.repository_owner }}/pulse:${{ needs.extract_version.outputs.version }} ghcr.io/${{ github.repository_owner }}/pulse:latest labels: | org.opencontainers.image.title=Pulse org.opencontainers.image.description=Proxmox monitoring system org.opencontainers.image.version=${{ needs.extract_version.outputs.tag }} org.opencontainers.image.created=${{ github.event.repository.updated_at }} org.opencontainers.image.revision=${{ github.sha }} org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} org.opencontainers.image.url=${{ github.server_url }}/${{ github.repository }} org.opencontainers.image.licenses=MIT - name: Build and push Docker agent image uses: docker/build-push-action@v6 with: context: . file: ./Dockerfile target: agent_runtime platforms: linux/amd64,linux/arm64 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 tags: | rcourtman/pulse-docker-agent:${{ needs.extract_version.outputs.tag }} rcourtman/pulse-docker-agent:${{ needs.extract_version.outputs.version }} rcourtman/pulse-docker-agent:latest ghcr.io/${{ github.repository_owner }}/pulse-docker-agent:${{ needs.extract_version.outputs.tag }} ghcr.io/${{ github.repository_owner }}/pulse-docker-agent:${{ needs.extract_version.outputs.version }} ghcr.io/${{ github.repository_owner }}/pulse-docker-agent:latest labels: | org.opencontainers.image.title=Pulse Docker Agent org.opencontainers.image.description=Docker container monitoring agent for Pulse org.opencontainers.image.version=${{ needs.extract_version.outputs.tag }} org.opencontainers.image.created=${{ github.event.repository.updated_at }} org.opencontainers.image.revision=${{ github.sha }} org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} org.opencontainers.image.url=${{ github.server_url }}/${{ github.repository }} org.opencontainers.image.licenses=MIT - name: Output Docker image information run: | echo "✅ Docker images built and pushed successfully!" echo "" echo "Server images:" echo " - rcourtman/pulse:${{ needs.extract_version.outputs.tag }}" echo " - rcourtman/pulse:${{ needs.extract_version.outputs.version }}" echo " - rcourtman/pulse:latest" echo "" echo "Agent images:" echo " - rcourtman/pulse-docker-agent:${{ needs.extract_version.outputs.tag }}" echo " - rcourtman/pulse-docker-agent:${{ needs.extract_version.outputs.version }}" echo " - rcourtman/pulse-docker-agent:latest" create_release: needs: - extract_version - build_docker_images 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 }} - 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 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}..." # GitHub API creates both the tag and release # If tag exists, it creates "untagged" release - so DON'T create tag first RELEASE_JSON=$(gh api "repos/${{ github.repository }}/releases" \ -X POST \ -F tag_name="${TAG}" \ -F target_commitish="${{ github.sha }}" \ -F name="Pulse ${TAG}" \ -F body="$(cat $NOTES_FILE)" \ -F draft=true) 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 "✓ 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: Output release information run: | echo "✅ Release draft created successfully!" echo "" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "📦 Release: ${{ needs.extract_version.outputs.tag }}" echo "🔗 URL: ${{ steps.create_release.outputs.release_url }}" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "" echo "⚠️ IMPORTANT: This release is in DRAFT status" echo "" echo "Next steps:" echo "1. Review the automatically generated release notes" echo "2. Edit and categorize changes as needed" echo "3. Publish the release when ready" echo "" echo "All artifacts have been uploaded." echo "Docker images are available at Docker Hub and GHCR." 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: true target_commitish: ${{ needs.create_release.outputs.target_commitish }}