name: Pulse Release Pipeline # Optimized: parallel jobs with consistent validation for RC and stable releases 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 permissions: contents: read concurrency: group: release-${{ github.event.inputs.version || github.ref || github.run_id }} cancel-in-progress: false jobs: # Combined version extraction and validation (saves a checkout) prepare: 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: | 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 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}" - name: Checkout repository uses: actions/checkout@v4 with: sparse-checkout: | VERSION - name: Validate VERSION file 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)" # Frontend checks run in parallel with backend tests frontend_checks: needs: prepare runs-on: ubuntu-latest timeout-minutes: 10 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 dependencies run: npm --prefix frontend-modern ci - name: Lint frontend run: npm --prefix frontend-modern run lint # Backend tests run in parallel with frontend checks backend_tests: needs: prepare runs-on: ubuntu-latest timeout-minutes: 30 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: Restore frontend build cache id: frontend-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 (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@v5 with: go-version: '1.24' 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 runs-on: ubuntu-latest timeout-minutes: 30 permissions: contents: read packages: write steps: - name: Checkout repository uses: actions/checkout@v4 - name: Set up QEMU if: needs.prepare.outputs.is_prerelease != 'true' uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Log in to GHCR uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build Docker image (verify only) uses: docker/build-push-action@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: 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 }} VERSION=${{ needs.prepare.outputs.tag }} - name: Build Docker agent image (verify only) uses: docker/build-push-action@v6 with: context: . file: ./Dockerfile target: agent_runtime platforms: ${{ needs.prepare.outputs.is_prerelease == 'true' && 'linux/amd64' || 'linux/amd64,linux/arm64' }} push: false 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 }} VERSION=${{ needs.prepare.outputs.tag }} # Integration tests run for both prereleases and stable releases integration_tests: needs: - prepare - backend_tests runs-on: ubuntu-latest timeout-minutes: 20 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: 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: 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@v5 with: go-version: '1.24' 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: Run integration 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 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 UPDATE_API_BASE_URL=http://localhost:7655 go test ../../tests/integration/api -run TestUpdateFlowIntegration -count=1 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 - integration_tests # Publish only after all validation jobs, including integration tests, pass if: ${{ always() && needs.frontend_checks.result == 'success' && needs.backend_tests.result == 'success' && needs.docker_build.result == 'success' && needs.integration_tests.result == 'success' }} 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 - 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: | 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.prepare.outputs.tag }}..." ./scripts/build-release.sh ${{ needs.prepare.outputs.version }} env: PULSE_LICENSE_PUBLIC_KEY: ${{ secrets.PULSE_LICENSE_PUBLIC_KEY }} - name: Post-build health check run: | if [ -x ./pulse ]; then ./pulse --version elif [ -x ./cmd/pulse/pulse ]; then ./cmd/pulse/pulse --version fi - 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 "Release $VERSION" > "$NOTES_FILE" echo "" >> "$NOTES_FILE" echo "See commit history for changes." >> "$NOTES_FILE" fi { echo "" echo "## Installation" echo "" echo "If you run Pulse via Docker or Compose, update to \`rcourtman/pulse:${VERSION}\`." echo "" echo "See the [Installation Guide](https://github.com/rcourtman/Pulse#installation) for other deployment methods." } >> "$NOTES_FILE" echo "notes_file=${NOTES_FILE}" >> $GITHUB_OUTPUT - name: Create tag env: GH_TOKEN: ${{ github.token }} run: | TAG="${{ needs.prepare.outputs.tag }}" HEAD_SHA=$(git rev-parse HEAD) 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" 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 }}" EXISTING_RELEASE=$(gh api "repos/${{ github.repository }}/releases/tags/${TAG}" 2>/dev/null || echo "") RELEASE_ID=$(echo "$EXISTING_RELEASE" | jq -r '.id // empty') if [ -n "$RELEASE_ID" ]; then RELEASE_URL=$(echo "$EXISTING_RELEASE" | jq -r '.html_url') IS_DRAFT=$(echo "$EXISTING_RELEASE" | jq -r '.draft') if [ "$IS_DRAFT" = "true" ]; then echo "Updating existing draft release for ${TAG}" gh api "repos/${{ github.repository }}/releases/${RELEASE_ID}" \ -X PATCH \ -F body="$(cat $NOTES_FILE)" \ -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 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 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 }}" gh release upload "${TAG}" release/checksums.txt --clobber gh release upload "${TAG}" release/*.sha256 --clobber - name: Upload release assets env: GH_TOKEN: ${{ github.token }} run: | TAG="${{ needs.prepare.outputs.tag }}" gh release upload "${TAG}" release/*.tar.gz --clobber gh release upload "${TAG}" release/*.zip --clobber if ls release/*.tgz 1> /dev/null 2>&1; then gh release upload "${TAG}" release/*.tgz --clobber fi gh release upload "${TAG}" release/install.sh --clobber gh release upload "${TAG}" release/install-docker.sh --clobber gh release upload "${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 }} run: | gh workflow run publish-docker.yml -f tag="${{ needs.prepare.outputs.tag }}" echo "[OK] Docker publish workflow dispatched" - name: Trigger demo server update if: ${{ github.event.inputs.draft_only != 'true' }} continue-on-error: true env: GH_TOKEN: ${{ secrets.WORKFLOW_PAT }} run: | gh workflow run update-demo-server.yml -f tag="${{ needs.prepare.outputs.tag }}" echo "[OK] Demo server update dispatched" - name: Summary run: | echo "[SUCCESS] Release published!" echo "Release: ${{ needs.prepare.outputs.tag }}" echo "URL: ${{ steps.create_release.outputs.release_url }}" validate_release_assets: needs: - prepare - create_release permissions: contents: write issues: 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: false target_commitish: ${{ needs.create_release.outputs.target_commitish }}