mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-02-18 23:41:48 +01:00
The release workflow publishes via GitHub API (patching draft to published), which doesn't fire the release webhook. This meant the Docker publish workflow was never triggered automatically. Added explicit workflow dispatch for publish-docker.yml after release publish, similar to how update-demo-server.yml was already dispatched. Related to #797
499 lines
18 KiB
YAML
499 lines
18 KiB
YAML
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:
|
|
- 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,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: |
|
|
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,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: |
|
|
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 .
|
|
|
|
- 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 }}
|
|
|
|
- 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
|
|
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)
|
|
|
|
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
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
run: |
|
|
TAG="${{ needs.extract_version.outputs.tag }}"
|
|
RELEASE_ID="${{ steps.create_release.outputs.release_id }}"
|
|
|
|
echo "Publishing release ${TAG}..."
|
|
|
|
gh api "repos/${{ github.repository }}/releases/${RELEASE_ID}" \
|
|
-X PATCH \
|
|
-F draft=false \
|
|
-F make_latest=true
|
|
|
|
echo "✓ Release published as latest: ${TAG}"
|
|
|
|
- name: Trigger Docker image publish
|
|
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"
|
|
|
|
- name: Trigger demo server update
|
|
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 }}
|
|
|
|
|