Files
pulse/.github/workflows/create-release.yml
T
rcourtman 7c35977fcb Fix post-publish pipeline failures for v6.0.0-rc.6
promote-floating-tags.yml waited on rcourtman/pulse-agent:${TAG} and
promoted floating tags for it, but publish-docker.yml never pushes the
agent image — Pulse Agent ships as GitHub Release binaries
(publish-docker.yml line 199 confirms). The wait timed out after 5
minutes on every release since the pulse-agent push step was removed,
leaving floating tags unpromoted. Stripped the vestigial wait block,
the agent promote step, and the agent line in the summary. Updated
header comments in both files to drop the past-tense reference.

install-sh-smoke.yml booted jrei/systemd-debian:12 without
--cgroupns=host, so on GHA ubuntu-24.04 (cgroup v2 unified hierarchy)
the container's systemd PID 1 exited before mounting the cgroup tree
and the container disappeared during readiness polling. Added
--cgroupns=host, an explicit /run/lock tmpfs, and dropped --rm so the
container persists for diagnostic capture on failure (trap handles
cleanup). Added an "is container still running" probe inside the
readiness loop and richer diagnostic output on timeout.
2026-05-27 22:08:15 +01:00

1141 lines
47 KiB
YAML

name: Pulse Release Pipeline
# Optimized: parallel jobs, fast prerelease 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
promoted_from_tag:
description: 'Stable only: prerelease tag being promoted (for example 6.0.0-rc.2)'
required: false
type: string
rollback_version:
description: 'Required: prior stable version to pin for rollback (for example 5.1.14 or v5.1.14)'
required: true
type: string
ga_date:
description: 'First stable v6.0.0 GA only: exact GA publish date (YYYY-MM-DD)'
required: false
type: string
v5_eos_date:
description: 'First stable v6.0.0 GA only: Pulse v5 end-of-support date (YYYY-MM-DD)'
required: false
type: string
hotfix_exception:
description: 'Stable only: bypass the 72-hour prerelease soak for urgent customer harm'
required: false
type: boolean
default: false
hotfix_reason:
description: 'Stable only: reason for hotfix soak exception'
required: false
type: string
historical_asset_backfill_only:
description: 'Repair an already-published release packet in place without rebuilding binaries'
required: false
type: boolean
default: false
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
permissions:
contents: read
jobs:
# Combined version extraction and validation (saves a checkout)
prepare:
runs-on: ubuntu-24.04
timeout-minutes: 5
outputs:
version: ${{ steps.extract.outputs.version }}
tag: ${{ steps.extract.outputs.tag }}
is_prerelease: ${{ steps.extract.outputs.is_prerelease }}
source_branch: ${{ steps.extract.outputs.source_branch }}
required_branch: ${{ steps.branch_policy.outputs.required_branch }}
promoted_from_tag: ${{ steps.promotion.outputs.promoted_from_tag }}
rollback_tag: ${{ steps.promotion.outputs.rollback_tag }}
rollback_command: ${{ steps.promotion.outputs.rollback_command }}
ga_date: ${{ steps.promotion.outputs.ga_date }}
v5_eos_date: ${{ steps.promotion.outputs.v5_eos_date }}
hotfix_exception: ${{ steps.promotion.outputs.hotfix_exception }}
hotfix_reason: ${{ steps.promotion.outputs.hotfix_reason }}
historical_asset_backfill_only: ${{ steps.extract.outputs.historical_asset_backfill_only }}
steps:
- name: Extract version
id: extract
run: |
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}"
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
if [[ "${GITHUB_REF}" != refs/heads/* ]]; then
echo "::error::Release workflow must be dispatched from a branch ref (current ref: ${GITHUB_REF})."
exit 1
fi
SOURCE_BRANCH="${GITHUB_REF_NAME}"
HISTORICAL_ASSET_BACKFILL_ONLY=$(jq -r '.inputs.historical_asset_backfill_only // "false"' "$GITHUB_EVENT_PATH" 2>/dev/null || echo "false")
echo "tag=${TAG}" >> $GITHUB_OUTPUT
echo "version=${VERSION}" >> $GITHUB_OUTPUT
echo "is_prerelease=${IS_PRERELEASE}" >> $GITHUB_OUTPUT
echo "source_branch=${SOURCE_BRANCH}" >> $GITHUB_OUTPUT
echo "historical_asset_backfill_only=${HISTORICAL_ASSET_BACKFILL_ONLY}" >> $GITHUB_OUTPUT
echo "Version: ${VERSION}, Tag: ${TAG}, Prerelease: ${IS_PRERELEASE}, Branch: ${SOURCE_BRANCH}, HistoricalBackfillOnly: ${HISTORICAL_ASSET_BACKFILL_ONLY}"
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
sparse-checkout: |
VERSION
docs/release-control/control_plane.json
scripts/release_control/control_plane.py
scripts/release_control/repo_file_io.py
- name: Resolve required release branch
id: branch_policy
run: |
REQUIRED_BRANCH="$(python3 scripts/release_control/control_plane.py --branch-for-version "${{ steps.extract.outputs.version }}")"
if [ "${{ steps.extract.outputs.source_branch }}" != "$REQUIRED_BRANCH" ]; then
echo "::error::Invalid release line. Version ${{ steps.extract.outputs.version }} must run from ${REQUIRED_BRANCH}, but workflow ref is ${{ steps.extract.outputs.source_branch }}."
exit 1
fi
echo "required_branch=${REQUIRED_BRANCH}" >> "$GITHUB_OUTPUT"
echo "[OK] Governed release branch for ${{ steps.extract.outputs.version }} is ${REQUIRED_BRANCH}"
- name: Validate VERSION file
if: ${{ steps.extract.outputs.historical_asset_backfill_only != 'true' }}
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)"
- name: Validate promotion policy
if: ${{ steps.extract.outputs.historical_asset_backfill_only != 'true' }}
id: promotion
env:
VERSION: ${{ steps.extract.outputs.version }}
TAG: ${{ steps.extract.outputs.tag }}
REQUIRED_BRANCH: ${{ steps.branch_policy.outputs.required_branch }}
IS_PRERELEASE: ${{ steps.extract.outputs.is_prerelease }}
PROMOTED_FROM_TAG_INPUT: ${{ github.event.inputs.promoted_from_tag }}
ROLLBACK_VERSION_INPUT: ${{ github.event.inputs.rollback_version }}
GA_DATE_INPUT: ${{ github.event.inputs.ga_date }}
V5_EOS_DATE_INPUT: ${{ github.event.inputs.v5_eos_date }}
HOTFIX_EXCEPTION_INPUT: ${{ github.event.inputs.hotfix_exception }}
HOTFIX_REASON_INPUT: ${{ github.event.inputs.hotfix_reason }}
run: |
set -euo pipefail
git fetch --prune origin main "${REQUIRED_BRANCH}" --tags
RELEASE_NOTES_INPUT="$(jq -r '.inputs.release_notes // ""' "$GITHUB_EVENT_PATH")"
NOTES_FILE="$(mktemp)"
printf '%s\n' "$RELEASE_NOTES_INPUT" > "$NOTES_FILE"
HELPER_ARGS=(
--version "${VERSION}"
--promoted-from-tag "${PROMOTED_FROM_TAG_INPUT:-}"
--rollback-version "${ROLLBACK_VERSION_INPUT:-}"
--ga-date "${GA_DATE_INPUT:-}"
--v5-eos-date "${V5_EOS_DATE_INPUT:-}"
--hotfix-reason "${HOTFIX_REASON_INPUT:-}"
--release-notes-file "$NOTES_FILE"
)
if [ "${HOTFIX_EXCEPTION_INPUT:-false}" = "true" ]; then
HELPER_ARGS+=(--hotfix-exception)
fi
python3 scripts/release_control/resolve_release_promotion.py "${HELPER_ARGS[@]}" > "$RUNNER_TEMP/promotion-metadata.out"
rm -f "$NOTES_FILE"
{
cat "$RUNNER_TEMP/promotion-metadata.out"
} >> "$GITHUB_OUTPUT"
echo "[OK] Promotion policy validated for ${TAG}"
# Frontend checks run in parallel with backend tests
frontend_checks:
needs: prepare
if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' }}
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Set up Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # 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
- name: Audit header composition
run: npm --prefix frontend-modern run lint:headers
- name: Check frontend copy-paste duplication
run: npm --prefix frontend-modern run lint:cpd
# Backend tests run in parallel with frontend checks
backend_tests:
needs: prepare
if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' }}
runs-on: ubuntu-24.04
timeout-minutes: 30
env:
FRONTEND_DIST: frontend-modern/dist
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Set up Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # 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@0057852bfaa89a56745cba8c7296529d2fc39830 # 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@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version: '1.25.9'
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
if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' }}
runs-on: ubuntu-24.04
timeout-minutes: 30
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Set up QEMU
if: needs.prepare.outputs.is_prerelease != 'true'
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- name: Log in to GHCR
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Derive license public key Docker cache key
id: license_key_cache
env:
PULSE_LICENSE_PUBLIC_KEY: ${{ secrets.PULSE_LICENSE_PUBLIC_KEY }}
run: |
set -euo pipefail
decoded_len="$(printf '%s' "${PULSE_LICENSE_PUBLIC_KEY}" | base64 -d | wc -c | tr -d ' ')"
if [ "${decoded_len}" != "32" ]; then
echo "PULSE_LICENSE_PUBLIC_KEY must decode to 32 bytes." >&2
exit 1
fi
key_sha256="$(printf '%s' "${PULSE_LICENSE_PUBLIC_KEY}" | base64 -d | sha256sum | awk '{print $1}')"
echo "sha256=${key_sha256}" >> "${GITHUB_OUTPUT}"
- name: Build Docker image (verify only)
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # 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: mode=max
sbom: true
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: |
VERSION=${{ needs.prepare.outputs.tag }}
PULSE_LICENSE_PUBLIC_KEY_SHA256=${{ steps.license_key_cache.outputs.sha256 }}
PULSE_UPDATE_SIGNING_PUBLIC_KEY=${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }}
secrets: |
pulse_license_public_key=${{ secrets.PULSE_LICENSE_PUBLIC_KEY }}
pulse_update_signing_key=${{ secrets.PULSE_UPDATE_SIGNING_KEY }}
- name: Build Pulse agent image (verify only)
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
with:
context: .
file: ./Dockerfile
target: agent_runtime
platforms: ${{ needs.prepare.outputs.is_prerelease == 'true' && 'linux/amd64' || 'linux/amd64,linux/arm64' }}
push: false
provenance: mode=max
sbom: true
cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/pulse-agent:buildcache
cache-to: type=registry,ref=ghcr.io/${{ github.repository_owner }}/pulse-agent:buildcache,mode=max
build-args: |
VERSION=${{ needs.prepare.outputs.tag }}
PULSE_LICENSE_PUBLIC_KEY_SHA256=${{ steps.license_key_cache.outputs.sha256 }}
PULSE_UPDATE_SIGNING_PUBLIC_KEY=${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }}
secrets: |
pulse_license_public_key=${{ secrets.PULSE_LICENSE_PUBLIC_KEY }}
pulse_update_signing_key=${{ secrets.PULSE_UPDATE_SIGNING_KEY }}
helm_smoke:
needs: prepare
if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' }}
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Set up Helm
uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4
with:
version: v3.15.2
- name: Build local Pulse runtime image for Helm smoke
env:
DOCKER_BUILDKIT: 1
PULSE_LICENSE_PUBLIC_KEY: ${{ secrets.PULSE_LICENSE_PUBLIC_KEY }}
PULSE_UPDATE_SIGNING_KEY: ${{ secrets.PULSE_UPDATE_SIGNING_KEY }}
PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }}
run: |
PULSE_LICENSE_PUBLIC_KEY_SHA256="$(printf '%s' "${PULSE_LICENSE_PUBLIC_KEY}" | base64 -d | sha256sum | awk '{print $1}')"
docker build \
--target runtime \
--secret id=pulse_license_public_key,env=PULSE_LICENSE_PUBLIC_KEY \
--secret id=pulse_update_signing_key,env=PULSE_UPDATE_SIGNING_KEY \
--build-arg VERSION="${{ needs.prepare.outputs.tag }}" \
--build-arg PULSE_LICENSE_PUBLIC_KEY_SHA256="${PULSE_LICENSE_PUBLIC_KEY_SHA256}" \
--build-arg PULSE_UPDATE_SIGNING_PUBLIC_KEY="${PULSE_UPDATE_SIGNING_PUBLIC_KEY}" \
-t pulse-helm-smoke:${{ needs.prepare.outputs.version }} \
.
- name: Helm smoke test with local release-line image
env:
SMOKE_IMAGE_REPOSITORY: pulse-helm-smoke
SMOKE_IMAGE_TAG: ${{ needs.prepare.outputs.version }}
run: |
set -euo pipefail
cleanup() {
kind delete cluster --name pulse-test >/dev/null 2>&1 || true
}
diagnose() {
echo "::group::helm status"
helm status pulse || true
echo "::endgroup::"
echo "::group::kubectl get all"
kubectl get all -A || true
echo "::endgroup::"
echo "::group::kubectl describe pods"
kubectl describe pods -A || true
echo "::endgroup::"
echo "::group::pod logs"
pods=$(kubectl get pods -A -o name 2>/dev/null || true)
for pod in $pods; do
echo "### ${pod}"
kubectl logs --all-containers=true --tail=200 "$pod" || true
done
echo "::endgroup::"
echo "::group::events"
kubectl get events -A --sort-by=.lastTimestamp || kubectl get events -A || true
echo "::endgroup::"
cleanup
}
trap 'diagnose' ERR
curl -Lo ./kind https://kind.sigs.k8s.io/dl/v0.20.0/kind-linux-amd64
chmod +x ./kind
sudo mv ./kind /usr/local/bin/kind
kind create cluster --name pulse-test --wait 5m
kind load docker-image "${SMOKE_IMAGE_REPOSITORY}:${SMOKE_IMAGE_TAG}" --name pulse-test
helm install pulse deploy/helm/pulse \
--set persistence.enabled=false \
--set server.secretEnv.create=true \
--set server.secretEnv.data.API_TOKENS=test-token \
--set image.repository="${SMOKE_IMAGE_REPOSITORY}" \
--set image.tag="${SMOKE_IMAGE_TAG}" \
--set image.pullPolicy=Never \
--wait --timeout 5m --debug
kubectl wait --for=condition=ready pod -l app.kubernetes.io/name=pulse --timeout=180s || (kubectl describe pods -l app.kubernetes.io/name=pulse && exit 1)
kubectl get pods -l app.kubernetes.io/name=pulse
helm upgrade pulse deploy/helm/pulse \
--set persistence.enabled=false \
--set server.secretEnv.create=true \
--set server.secretEnv.data.API_TOKENS=test-token \
--set image.repository="${SMOKE_IMAGE_REPOSITORY}" \
--set image.tag="${SMOKE_IMAGE_TAG}" \
--set image.pullPolicy=Never \
--wait --timeout 5m --debug
trap - ERR
cleanup
echo "✓ Helm smoke test passed"
# Integration tests - skipped for prereleases (they've been tested in CI)
integration_tests:
needs:
- prepare
- backend_tests
if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' && needs.prepare.outputs.is_prerelease != 'true' }}
runs-on: ubuntu-24.04
timeout-minutes: 45
env:
FRONTEND_DIST: frontend-modern/dist
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Set up Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: 'frontend-modern/package-lock.json'
- name: Restore frontend build cache
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # 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@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version: '1.25.9'
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: Install integration test dependencies
working-directory: tests/integration
run: |
npm ci
npx playwright install --with-deps chromium
- 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"
PULSE_MULTI_TENANT_ENABLED: "true"
PULSE_E2E_ENTITLEMENT_PROFILE: "multi-tenant"
PULSE_E2E_BOOTSTRAP_TOKEN: 0123456789abcdef0123456789abcdef0123456789abcdef
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
node scripts/apply-entitlement-profile.mjs
echo "Running update API route smoke check..."
STATUS=$(curl -s -o /tmp/update-status.json -w "%{http_code}" http://localhost:7655/api/updates/status || true)
echo "Update status endpoint returned HTTP ${STATUS}"
case "${STATUS}" in
200|401|403)
;;
*)
echo "Unexpected response from /api/updates/status"
cat /tmp/update-status.json || true
exit 1
;;
esac
echo "Running multi-tenant E2E suite..."
npx playwright test tests/03-multi-tenant.spec.ts --project=chromium --reporter=list
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
- helm_smoke
- integration_tests
# Run if integration_tests passed OR was skipped (prereleases)
if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' && always() && needs.frontend_checks.result == 'success' && needs.backend_tests.result == 'success' && needs.docker_build.result == 'success' && needs.helm_smoke.result == 'success' && (needs.integration_tests.result == 'success' || needs.integration_tests.result == 'skipped') }}
runs-on: ubuntu-24.04
timeout-minutes: 30
permissions:
contents: write
id-token: write
attestations: 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@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version: '1.25.9'
cache: true
- name: Set up Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # 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@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4
with:
version: 'v3.15.2'
- name: Install Syft
run: |
set -euo pipefail
SYFT_VERSION="1.42.4"
SYFT_ARCHIVE="syft_${SYFT_VERSION}_linux_amd64.tar.gz"
SYFT_SHA256="590650c2743b83f327d1bf9bec64f6f83b7fec504187bb84f500c862bf8f2a0f"
TMP_DIR="$(mktemp -d)"
trap 'rm -rf "$TMP_DIR"' EXIT
curl -fsSL "https://github.com/anchore/syft/releases/download/v${SYFT_VERSION}/${SYFT_ARCHIVE}" \
-o "${TMP_DIR}/${SYFT_ARCHIVE}"
printf '%s %s\n' "${SYFT_SHA256}" "${TMP_DIR}/${SYFT_ARCHIVE}" | sha256sum --check --
tar -xzf "${TMP_DIR}/${SYFT_ARCHIVE}" -C "${TMP_DIR}" syft
install -m 0755 "${TMP_DIR}/syft" /usr/local/bin/syft
syft version
- 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 }}
PULSE_UPDATE_SIGNING_KEY: ${{ secrets.PULSE_UPDATE_SIGNING_KEY }}
PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }}
- name: Validate installer signing key pins
env:
PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }}
run: |
set -euo pipefail
TRUSTED_SSH_PUBLIC_KEY="$(
go run ./scripts/release_update_key.go public-key-ssh \
--public-key "${PULSE_UPDATE_SIGNING_PUBLIC_KEY}" \
--comment pulse-installer
)"
for installer in install.sh scripts/pulse-auto-update.sh release/pulse-auto-update.sh; do
grep -F "PINNED_RELEASE_SSH_PUBLIC_KEY=\"${TRUSTED_SSH_PUBLIC_KEY}\"" "${installer}" >/dev/null || {
echo "::error::${installer} does not trust the configured release signing key."
exit 1
}
done
- name: Post-build health check
run: |
if [ -x ./pulse ]; then
./pulse --version
elif [ -x ./cmd/pulse/pulse ]; then
./cmd/pulse/pulse --version
fi
- name: Attest release assets
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4
with:
subject-path: release/*
- 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 "# Pulse v${VERSION} Release Notes" > "$NOTES_FILE"
echo "" >> "$NOTES_FILE"
echo "See commit history for changes." >> "$NOTES_FILE"
fi
RENDERED_NOTES_FILE=$(mktemp)
python3 scripts/release_control/render_release_body.py \
--version "$VERSION" \
--release-notes-file "$NOTES_FILE" \
--output "$RENDERED_NOTES_FILE" \
--promotion-channel "${{ needs.prepare.outputs.is_prerelease == 'true' && 'rc' || 'stable' }}" \
--candidate-tag "${{ needs.prepare.outputs.tag }}" \
--promoted-prerelease-tag "${{ needs.prepare.outputs.promoted_from_tag }}" \
--rollback-target "${{ needs.prepare.outputs.rollback_tag }}" \
--rollback-command "${{ needs.prepare.outputs.rollback_command }}" \
--planned-ga-date "${{ needs.prepare.outputs.ga_date }}" \
--planned-v5-eos-date "${{ needs.prepare.outputs.v5_eos_date }}" \
--hotfix-exception "${{ needs.prepare.outputs.hotfix_exception }}" \
--hotfix-reason "${{ needs.prepare.outputs.hotfix_reason }}"
echo "notes_file=${RENDERED_NOTES_FILE}" >> $GITHUB_OUTPUT
- name: Locate existing release
id: existing_release
env:
GH_TOKEN: ${{ github.token }}
run: |
TAG="${{ needs.prepare.outputs.tag }}"
EXISTING_RELEASE=$(gh api "repos/${{ github.repository }}/releases?per_page=100" --paginate | jq -sc --arg tag "$TAG" 'add | map(select(.tag_name == $tag)) | first // empty')
RELEASE_ID=$(echo "$EXISTING_RELEASE" | jq -r '.id // empty')
RELEASE_URL=$(echo "$EXISTING_RELEASE" | jq -r '.html_url // empty')
RELEASE_IS_DRAFT=$(echo "$EXISTING_RELEASE" | jq -r '.draft // false')
RELEASE_PUBLISHED_AT=$(echo "$EXISTING_RELEASE" | jq -r '.published_at // empty')
echo "release_id=${RELEASE_ID}" >> $GITHUB_OUTPUT
echo "release_url=${RELEASE_URL}" >> $GITHUB_OUTPUT
echo "release_is_draft=${RELEASE_IS_DRAFT}" >> $GITHUB_OUTPUT
echo "release_published_at=${RELEASE_PUBLISHED_AT}" >> $GITHUB_OUTPUT
- name: Create tag
env:
GH_TOKEN: ${{ github.token }}
run: |
TAG="${{ needs.prepare.outputs.tag }}"
HEAD_SHA=$(git rev-parse HEAD)
EXISTING_RELEASE_ID="${{ steps.existing_release.outputs.release_id }}"
EXISTING_RELEASE_DRAFT="${{ steps.existing_release.outputs.release_is_draft }}"
EXISTING_RELEASE_PUBLISHED_AT="${{ steps.existing_release.outputs.release_published_at }}"
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"
elif [ -n "$EXISTING_RELEASE_ID" ] && [ "$EXISTING_RELEASE_DRAFT" = "true" ] && [ -z "$EXISTING_RELEASE_PUBLISHED_AT" ]; then
echo "Retargeting existing draft tag ${TAG} from ${REMOTE_COMMIT_SHA} to ${HEAD_SHA}"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag -fa "${TAG}" -m "Release ${TAG}" "${HEAD_SHA}"
git push origin "refs/tags/${TAG}" --force
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 }}"
HEAD_SHA=$(git rev-parse HEAD)
RELEASE_ID="${{ steps.existing_release.outputs.release_id }}"
RELEASE_URL="${{ steps.existing_release.outputs.release_url }}"
IS_DRAFT="${{ steps.existing_release.outputs.release_is_draft }}"
PUBLISHED_AT="${{ steps.existing_release.outputs.release_published_at }}"
if [ -n "$RELEASE_ID" ]; then
if [ "$IS_DRAFT" = "true" ] && [ -z "$PUBLISHED_AT" ]; then
echo "Updating existing draft release for ${TAG}"
gh api "repos/${{ github.repository }}/releases/${RELEASE_ID}" \
-X PATCH \
-F tag_name="${TAG}" \
-F target_commitish="${HEAD_SHA}" \
-F name="Pulse ${TAG}" \
-F body="$(cat "$NOTES_FILE")" \
-F draft=true \
-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 target_commitish="${HEAD_SHA}" \
-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
RELEASE_JSON=$(gh api "repos/${{ github.repository }}/releases/${RELEASE_ID}")
ACTUAL_RELEASE_TAG=$(echo "$RELEASE_JSON" | jq -r '.tag_name // empty')
ACTUAL_TARGET_COMMITISH=$(echo "$RELEASE_JSON" | jq -r '.target_commitish // empty')
RELEASE_URL=$(echo "$RELEASE_JSON" | jq -r '.html_url')
if [ "$ACTUAL_RELEASE_TAG" != "$TAG" ]; then
echo "::error::Draft release ${RELEASE_ID} is bound to tag ${ACTUAL_RELEASE_TAG}, expected ${TAG}."
exit 1
fi
if [ "$ACTUAL_TARGET_COMMITISH" != "$HEAD_SHA" ]; then
echo "::error::Draft release ${RELEASE_ID} target_commitish is ${ACTUAL_TARGET_COMMITISH}, expected ${HEAD_SHA}."
exit 1
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 }}"
release_upload_with_retry() {
local attempt=1
local max_attempts=5
local wait_seconds=15
while true; do
if gh release upload "$@"; then
return 0
fi
if [ "$attempt" -ge "$max_attempts" ]; then
echo "::error::gh release upload failed after ${max_attempts} attempts: $*"
return 1
fi
echo "gh release upload failed on attempt ${attempt}/${max_attempts}; retrying in ${wait_seconds}s: $*"
sleep "$wait_seconds"
attempt=$((attempt + 1))
if [ "$wait_seconds" -lt 120 ]; then
wait_seconds=$((wait_seconds * 2))
if [ "$wait_seconds" -gt 120 ]; then
wait_seconds=120
fi
fi
done
}
release_upload_with_retry "${TAG}" release/checksums.txt --clobber
release_upload_with_retry "${TAG}" release/*.sha256 --clobber
if ls release/*.sig 1> /dev/null 2>&1; then
release_upload_with_retry "${TAG}" release/*.sig --clobber
fi
if ls release/*.sshsig 1> /dev/null 2>&1; then
release_upload_with_retry "${TAG}" release/*.sshsig --clobber
fi
- name: Upload release assets
env:
GH_TOKEN: ${{ github.token }}
run: |
TAG="${{ needs.prepare.outputs.tag }}"
release_upload_with_retry() {
local attempt=1
local max_attempts=5
local wait_seconds=15
while true; do
if gh release upload "$@"; then
return 0
fi
if [ "$attempt" -ge "$max_attempts" ]; then
echo "::error::gh release upload failed after ${max_attempts} attempts: $*"
return 1
fi
echo "gh release upload failed on attempt ${attempt}/${max_attempts}; retrying in ${wait_seconds}s: $*"
sleep "$wait_seconds"
attempt=$((attempt + 1))
if [ "$wait_seconds" -lt 120 ]; then
wait_seconds=$((wait_seconds * 2))
if [ "$wait_seconds" -gt 120 ]; then
wait_seconds=120
fi
fi
done
}
if ls release/*.sbom.spdx.json 1> /dev/null 2>&1; then
release_upload_with_retry "${TAG}" release/*.sbom.spdx.json --clobber
fi
release_upload_with_retry "${TAG}" release/*.tar.gz --clobber
release_upload_with_retry "${TAG}" release/*.zip --clobber
if ls release/*.tgz 1> /dev/null 2>&1; then
release_upload_with_retry "${TAG}" release/*.tgz --clobber
fi
for bare_agent in \
release/pulse-agent-linux-amd64 \
release/pulse-agent-linux-arm64 \
release/pulse-agent-linux-armv7 \
release/pulse-agent-linux-armv6 \
release/pulse-agent-linux-386 \
release/pulse-agent-freebsd-amd64 \
release/pulse-agent-freebsd-arm64 \
release/pulse-agent-windows-amd64.exe \
release/pulse-agent-windows-arm64.exe \
release/pulse-agent-windows-386.exe; do
if [ -f "${bare_agent}" ]; then
release_upload_with_retry "${TAG}" "${bare_agent}" --clobber
fi
done
for bare_mcp in \
release/pulse-mcp-linux-amd64 \
release/pulse-mcp-linux-arm64 \
release/pulse-mcp-linux-armv7 \
release/pulse-mcp-linux-armv6 \
release/pulse-mcp-linux-386 \
release/pulse-mcp-darwin-amd64 \
release/pulse-mcp-darwin-arm64 \
release/pulse-mcp-freebsd-amd64 \
release/pulse-mcp-freebsd-arm64 \
release/pulse-mcp-windows-amd64.exe \
release/pulse-mcp-windows-arm64.exe \
release/pulse-mcp-windows-386.exe; do
if [ -f "${bare_mcp}" ]; then
release_upload_with_retry "${TAG}" "${bare_mcp}" --clobber
fi
done
release_upload_with_retry "${TAG}" release/install.sh --clobber
if [ -f release/install.ps1 ]; then
release_upload_with_retry "${TAG}" release/install.ps1 --clobber
fi
if [ -f release/install-mcp.sh ]; then
release_upload_with_retry "${TAG}" release/install-mcp.sh --clobber
fi
if [ -f release/install-mcp.ps1 ]; then
release_upload_with_retry "${TAG}" release/install-mcp.ps1 --clobber
fi
release_upload_with_retry "${TAG}" release/install-docker.sh --clobber
release_upload_with_retry "${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 }}
REQUIRED_BRANCH: ${{ needs.prepare.outputs.required_branch }}
run: |
gh workflow run publish-docker.yml --ref "${REQUIRED_BRANCH}" -f tag="${{ needs.prepare.outputs.tag }}"
echo "[OK] Docker publish workflow dispatched from ${REQUIRED_BRANCH}"
- name: Trigger demo server update
if: ${{ github.event.inputs.draft_only != 'true' }}
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.WORKFLOW_PAT }}
REQUIRED_BRANCH: ${{ needs.prepare.outputs.required_branch }}
run: |
if [ "${{ needs.prepare.outputs.is_prerelease }}" = "true" ]; then
TARGET="preview-v6"
else
TARGET="stable"
fi
gh workflow run update-demo-server.yml --ref "${REQUIRED_BRANCH}" -f tag="${{ needs.prepare.outputs.tag }}" -f target="${TARGET}"
echo "[OK] Demo server update dispatched for ${TARGET} from ${REQUIRED_BRANCH}"
- name: Summary
run: |
echo "[SUCCESS] Release published!"
echo "Release: ${{ needs.prepare.outputs.tag }}"
echo "URL: ${{ steps.create_release.outputs.release_url }}"
backfill_release_assets:
needs:
- prepare
if: ${{ needs.prepare.outputs.historical_asset_backfill_only == 'true' }}
runs-on: ubuntu-24.04
timeout-minutes: 30
permissions:
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Set up Go
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version: '1.25.9'
cache: true
- name: Install Syft
run: |
set -euo pipefail
SYFT_VERSION="1.42.4"
SYFT_ARCHIVE="syft_${SYFT_VERSION}_linux_amd64.tar.gz"
SYFT_SHA256="590650c2743b83f327d1bf9bec64f6f83b7fec504187bb84f500c862bf8f2a0f"
TMP_DIR="$(mktemp -d)"
trap 'rm -rf "$TMP_DIR"' EXIT
curl -fsSL "https://github.com/anchore/syft/releases/download/v${SYFT_VERSION}/${SYFT_ARCHIVE}" \
-o "${TMP_DIR}/${SYFT_ARCHIVE}"
printf '%s %s\n' "${SYFT_SHA256}" "${TMP_DIR}/${SYFT_ARCHIVE}" | sha256sum --check --
tar -xzf "${TMP_DIR}/${SYFT_ARCHIVE}" -C "${TMP_DIR}" syft
install -m 0755 "${TMP_DIR}/syft" /usr/local/bin/syft
syft version
- name: Backfill published release assets
env:
GH_TOKEN: ${{ github.token }}
PULSE_UPDATE_SIGNING_KEY: ${{ secrets.PULSE_UPDATE_SIGNING_KEY }}
PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }}
run: |
./scripts/backfill-release-assets.sh --tag "${{ needs.prepare.outputs.tag }}" --repo "${{ github.repository }}"
- name: Validate published release packet
run: |
./scripts/validate-published-release.sh "${{ needs.prepare.outputs.tag }}" "${{ github.repository }}"
- name: Summary
run: |
echo "[SUCCESS] Historical release assets repaired"
echo "Release: ${{ needs.prepare.outputs.tag }}"
validate_release_assets:
needs:
- prepare
- create_release
if: ${{ always() && needs.prepare.result == 'success' && needs.create_release.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' }}
permissions:
contents: write
issues: write
statuses: 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: ${{ github.event.inputs.draft_only == 'true' }}
target_commitish: ${{ needs.create_release.outputs.target_commitish }}
# End-to-end install.sh smoke against the just-published release. Catches
# runtime regressions in the documented Proxmox-LXC / systemd install flow
# that the build-time validate-release.sh checks cannot see: the script
# parses fine, signs cleanly, but fails to actually install or boot Pulse.
# This class of regression broke silently across v6 rc.1 → rc.5 because no
# existing gate exercised the documented secure-install commands against
# the published GitHub Release URL.
#
# Gated on validate_release_assets success — the smoke depends on the
# published asset bundle being well-formed, so we only run it after the
# cheaper content checks pass. Skipped for the historical-backfill path
# since that flow re-uploads to an already-published release and the
# smoke would just re-confirm what hasn't changed. Also skipped for
# draft-only runs because draft release assets are not available at the
# public /releases/download/<tag>/ URL this smoke intentionally exercises.
install_sh_smoke:
needs:
- prepare
- validate_release_assets
if: ${{ always() && needs.prepare.result == 'success' && needs.validate_release_assets.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' && github.event.inputs.draft_only != 'true' }}
permissions:
contents: read
uses: ./.github/workflows/install-sh-smoke.yml
secrets: inherit
with:
tag: ${{ needs.prepare.outputs.tag }}
version: ${{ needs.prepare.outputs.version }}
repository: ${{ github.repository }}
# Publish the Helm chart for this release. publish-helm-chart.yml also
# listens for `release: published` events directly, but the create_release
# publish step PATCHes a draft release to draft=false rather than creating
# it as draft=false from the start — that GitHub-documented path does NOT
# fire `release: published`. Across v6 rc.1 → rc.5 the release-event branch
# never triggered helm publish, leaving rcourtman.github.io/Pulse/index.yaml
# without any v6 chart and breaking `helm install pulse pulse/pulse
# --version 6.0.0-rc.5`. Calling the workflow explicitly here is the
# canonical fix. Draft-only runs must not publish the chart because the
# release has not crossed the operator-controlled publication boundary.
publish_helm_chart:
needs:
- prepare
- validate_release_assets
if: ${{ always() && needs.prepare.result == 'success' && needs.validate_release_assets.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' && github.event.inputs.draft_only != 'true' }}
permissions:
contents: write
packages: write
uses: ./.github/workflows/publish-helm-chart.yml
secrets: inherit
with:
chart_version: ${{ needs.prepare.outputs.version }}
app_version: ${{ needs.prepare.outputs.version }}
# Defensive backup to promote-floating-tags.yml's workflow_run chain off
# publish-docker.yml. The chain works when publish-docker succeeds, but
# when it fails the floating tags don't advance and customers pulling
# rcourtman/pulse:latest stay on whatever the previous successful release
# tagged. Calling promote-floating-tags as workflow_call after
# validate_release_assets succeeds (which itself waits for the docker
# image to be pullable) guarantees the floating tags advance. Draft-only
# runs must not promote floating tags because the release is still in
# private promotion state.
promote_floating_tags:
needs:
- prepare
- validate_release_assets
if: ${{ always() && needs.prepare.result == 'success' && needs.validate_release_assets.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' && github.event.inputs.draft_only != 'true' }}
permissions:
contents: read
packages: write
uses: ./.github/workflows/promote-floating-tags.yml
secrets: inherit
with:
tag: ${{ needs.prepare.outputs.tag }}
prerelease: ${{ needs.prepare.outputs.is_prerelease == 'true' }}