Files
Pulse/.github/workflows/create-release.yml

586 lines
22 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
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 }}"
HEAD_SHA=$(git rev-parse HEAD)
# Check if tag already exists on remote
REMOTE_TAG_SHA=$(git ls-remote --tags origin "refs/tags/${TAG}" | awk '{print $1}')
if [ -n "$REMOTE_TAG_SHA" ]; then
# Tag exists - check if it points to current HEAD
# For annotated tags, we need to dereference to get the commit
REMOTE_COMMIT_SHA=$(git ls-remote --tags origin "refs/tags/${TAG}^{}" | awk '{print $1}')
# If no dereferenced tag, it's a lightweight tag pointing directly to the commit
[ -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.extract_version.outputs.tag }}"
NOTES_FILE="${{ steps.generate_notes.outputs.notes_file }}"
IS_PRERELEASE="${{ needs.extract_version.outputs.is_prerelease }}"
# Check if a release already exists for this tag
# Note: gh api returns JSON error on 404, so check for valid release ID
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 "Draft release already exists for ${TAG} - updating it"
# Update the existing draft with new notes
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}. Cannot re-release the same version."
exit 1
fi
else
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
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"
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 --clobber
# Upload individual .sha256 files for backward compatibility
echo "Uploading .sha256 checksum files..."
gh release upload "${TAG}" release/*.sha256 --clobber
- 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 --clobber
# Upload Windows zip files
gh release upload "${TAG}" release/*.zip --clobber
# 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 --clobber
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 --clobber
gh release upload "${TAG}" release/install-sensor-proxy.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.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 }}