Pulse/.github/workflows/release.yml
Workflow config file is invalid. Please check your config file: yaml: line 353: did not find expected alphabetic or numeric character
rcourtman 00da544541 Switch to reliable workflow_dispatch trigger for releases
Tag push triggers in GitHub Actions are unreliable (known issue).
Major projects don't actually use automatic tag triggers - they use
workflow_dispatch or other manual triggers.

Changes:
- Remove tag push trigger
- Use workflow_dispatch with version input
- Workflow validates that annotated tag already exists
- Tag still stores LLM changelog in annotation
- Manual trigger: gh workflow run release.yml -f version=X.Y.Z

This is the pattern that actually works reliably.
2025-11-13 12:24:34 +00:00

495 lines
18 KiB
YAML

name: Release
on:
workflow_dispatch:
inputs:
version:
description: 'Version number (e.g., 4.30.0)'
required: true
type: string
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: |
VERSION="${{ inputs.version }}"
TAG="v${VERSION}"
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
with:
fetch-depth: 0 # Need full history to check tags
- 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)"
- name: Check if tag already exists
run: |
TAG="${{ needs.extract-version.outputs.tag }}"
if git rev-parse "$TAG" >/dev/null 2>&1; then
echo "✓ Tag $TAG already exists (will use existing tag annotation)"
else
echo "::error::Tag $TAG does not exist. Please create an annotated tag with release notes first:"
echo "::error:: git tag -a $TAG -F /tmp/release_notes_${{ needs.extract-version.outputs.version }}.md"
echo "::error:: git push origin $TAG"
exit 1
fi
preflight-tests:
needs: version-guard
runs-on: ubuntu-latest
timeout-minutes: 90
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: Build frontend bundle for Go embed
run: |
npm --prefix frontend-modern run build
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: go 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: 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 .
- 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: .
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'
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: |
# Install zip for Windows binaries
sudo apt-get update
sudo apt-get install -y zip
# Install Helm for chart packaging
curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
- name: Build release artifacts
run: |
echo "Building release ${{ needs.extract-version.outputs.tag }}..."
./scripts/build-release.sh ${{ needs.extract-version.outputs.version }}
- name: Extract release notes from tag annotation
id: generate_notes
run: |
TAG="${{ needs.extract-version.outputs.tag }}"
VERSION="${{ needs.extract-version.outputs.version }}"
echo "Reading release notes from tag annotation..."
# Check if tag has an annotation (release notes from Claude)
TAG_MESSAGE=$(git tag -l --format='%(contents)' "${TAG}")
if [ -z "$TAG_MESSAGE" ] || [ "$TAG_MESSAGE" = "" ]; then
echo "::error::Tag ${TAG} has no annotation. Release notes must be provided by Claude."
echo "::error::Tags must be created with: git tag -a ${TAG} -F /tmp/release_notes_${VERSION}.md"
echo "::error::This ensures Claude writes user-focused, semantic changelog instead of raw commits."
exit 1
fi
echo "✓ Found release notes in tag annotation"
# Save tag annotation to file
NOTES_FILE=$(mktemp)
echo "$TAG_MESSAGE" > "$NOTES_FILE"
# Add installation instructions
cat >> "$NOTES_FILE" << EOF
## Installation
**Docker (recommended):**
\`\`\`bash
docker pull rcourtman/pulse:${VERSION}
\`\`\`
**Docker Compose:**
Update your \`docker-compose.yml\` to use \`rcourtman/pulse:${VERSION}\`
See the [Installation Guide](https://github.com/rcourtman/Pulse#installation) for complete setup instructions.
EOF
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}..."
# Tag already exists (pushed by user), so don't create it
# Just create the release pointing to the existing tag
gh release create "${TAG}" \
--draft \
--title "Pulse ${TAG}" \
--notes-file "$NOTES_FILE"
rm -f "$NOTES_FILE"
# Get the release ID
echo "Waiting for release to appear in GitHub API..."
MAX_ATTEMPTS=10
ATTEMPT=1
RELEASE_JSON=""
while [ $ATTEMPT -le $MAX_ATTEMPTS ]; do
echo "Attempt $ATTEMPT/$MAX_ATTEMPTS: Looking for release ${TAG}..."
RELEASE_JSON=$(gh api "repos/${{ github.repository }}/releases" --paginate | jq ".[] | select(.tag_name == \"${TAG}\")")
if [ -n "$RELEASE_JSON" ]; then
echo "✓ Found release in API"
break
fi
if [ $ATTEMPT -lt $MAX_ATTEMPTS ]; then
echo "Release not indexed yet, waiting 2 seconds..."
sleep 2
fi
ATTEMPT=$((ATTEMPT + 1))
done
if [ -z "$RELEASE_JSON" ]; then
echo "::error::Failed to find release ${TAG} in API after $MAX_ATTEMPTS attempts"
exit 1
fi
RELEASE_ID=$(echo "$RELEASE_JSON" | jq -r '.id')
if [ -z "$RELEASE_ID" ] || [ "$RELEASE_ID" = "null" ]; then
echo "::error::Failed to extract release ID from API response"
exit 1
fi
echo "release_url=$(echo "$RELEASE_JSON" | jq -r '.html_url')" >> $GITHUB_OUTPUT
echo "release_id=${RELEASE_ID}" >> $GITHUB_OUTPUT
echo "✓ Release 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.sh
gh release upload "${TAG}" release/install.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 }}