Files
Pulse/.github/workflows/create-release.yml
rcourtman ac9003d105 fix: build pulse:test Docker image for integration tests
The docker-compose test stack expects a pulse:test image. Build it
from the Dockerfile before running docker compose.
2026-02-04 17:13:17 +00:00

532 lines
19 KiB
YAML

name: Pulse Release Pipeline
# Optimized: parallel jobs, fast RC path
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:
# 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 - skipped for prereleases (they've been tested in CI)
integration_tests:
needs:
- prepare
- backend_tests
if: ${{ needs.prepare.outputs.is_prerelease != 'true' }}
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
# Run if integration_tests passed OR was skipped (prereleases)
if: ${{ always() && needs.frontend_checks.result == 'success' && needs.backend_tests.result == 'success' && needs.docker_build.result == 'success' && (needs.integration_tests.result == 'success' || needs.integration_tests.result == 'skipped') }}
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 "**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
- 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
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 }}