Build releases once and promote verified candidates

This commit is contained in:
rcourtman 2026-07-09 22:21:34 +01:00
parent b26716b3c9
commit 8dda0b6efa
16 changed files with 896 additions and 200 deletions

View file

@ -0,0 +1,137 @@
name: Build Release Candidate
on:
workflow_call:
inputs:
version:
description: 'Version number without the leading v'
required: true
type: string
outputs:
artifact_name:
description: 'Immutable release candidate artifact name'
value: ${{ jobs.build.outputs.artifact_name }}
manifest_artifact_name:
description: 'Release candidate manifest artifact name'
value: ${{ jobs.build.outputs.manifest_artifact_name }}
permissions:
contents: read
jobs:
build:
name: Build and Validate Signed Candidate
runs-on: ubuntu-24.04
timeout-minutes: 35
outputs:
artifact_name: ${{ steps.identity.outputs.artifact_name }}
manifest_artifact_name: ${{ steps.identity.outputs.manifest_artifact_name }}
steps:
- name: Resolve candidate identity
id: identity
run: |
set -euo pipefail
echo "artifact_name=release-candidate-${GITHUB_SHA}-${{ inputs.version }}" >> "$GITHUB_OUTPUT"
echo "manifest_artifact_name=release-candidate-manifest-${GITHUB_SHA}-${{ inputs.version }}" >> "$GITHUB_OUTPUT"
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
- name: Validate candidate identity
run: |
set -euo pipefail
test "$(tr -d '\n' < VERSION)" = "${{ inputs.version }}"
test "$(git rev-parse HEAD)" = "${GITHUB_SHA}"
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: go.mod
cache: true
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: 'frontend-modern/package-lock.json'
- name: Install release prerequisites
run: |
sudo apt-get update
sudo apt-get install -y zip
- name: Set up Helm
uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 # v5.0.0
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
- name: Build signed release candidate
run: ./scripts/build-release.sh "${{ inputs.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: Validate complete candidate locally
run: ./scripts/validate-release.sh "${{ inputs.version }}" --skip-docker
- name: Create immutable candidate manifest
run: |
python3 scripts/release_candidate_manifest.py create \
--release-dir release \
--version "${{ inputs.version }}" \
--source-sha "${GITHUB_SHA}" \
--output release-candidate-manifest/release-candidate.json
- name: Upload immutable release candidate
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ${{ steps.identity.outputs.artifact_name }}
path: release/
if-no-files-found: error
retention-days: 1
compression-level: 0
overwrite: true
- name: Upload candidate manifest
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ${{ steps.identity.outputs.manifest_artifact_name }}
path: release-candidate-manifest/release-candidate.json
if-no-files-found: error
retention-days: 1
overwrite: true

View file

@ -84,7 +84,6 @@ jobs:
hotfix_reason: ${{ steps.promotion.outputs.hotfix_reason }}
promotion_mode: ${{ steps.promotion.outputs.promotion_mode }}
is_stable_patch: ${{ steps.promotion.outputs.is_stable_patch }}
preflight_run_url: ${{ steps.preflight.outputs.run_url }}
historical_asset_backfill_only: ${{ steps.extract.outputs.historical_asset_backfill_only }}
steps:
- name: Extract version
@ -209,35 +208,16 @@ jobs:
echo "[OK] Promotion policy validated for ${TAG}"
- name: Require recent exact-SHA stable patch preflight
id: preflight
if: ${{ steps.extract.outputs.historical_asset_backfill_only != 'true' && steps.promotion.outputs.is_stable_patch == 'true' }}
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ steps.extract.outputs.version }}
run: |
set -euo pipefail
CUTOFF="$(date -u -d '24 hours ago' '+%Y-%m-%dT%H:%M:%SZ')"
RUN="$(gh api "repos/${{ github.repository }}/actions/workflows/release-dry-run.yml/runs?event=workflow_dispatch&status=success&head_sha=${GITHUB_SHA}&per_page=20" \
| jq -c \
--arg sha "$GITHUB_SHA" \
--arg title "Release Dry Run v${VERSION}" \
--arg cutoff "$CUTOFF" \
'[.workflow_runs[] | select(
.conclusion == "success"
and .head_sha == $sha
and .display_title == $title
and .created_at >= $cutoff
)] | sort_by(.created_at) | last // empty')"
if [ -z "$RUN" ]; then
echo "::error::Stable patch release requires a successful Release Dry Run for v${VERSION} at exact commit ${GITHUB_SHA} within the last 24 hours. Run ./scripts/trigger-stable-patch.sh --dry-run ${VERSION}, wait for success, then dispatch once."
exit 1
fi
RUN_URL="$(jq -r '.html_url' <<<"$RUN")"
echo "run_url=${RUN_URL}" >> "$GITHUB_OUTPUT"
echo "[OK] Exact-SHA release preflight passed: ${RUN_URL}"
build_release_candidate:
name: Build Immutable Release Candidate
needs: prepare
if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' }}
permissions:
contents: read
uses: ./.github/workflows/build-release-candidate.yml
secrets: inherit
with:
version: ${{ needs.prepare.outputs.version }}
# Frontend checks run in parallel with backend tests
frontend_checks:
@ -268,9 +248,24 @@ jobs:
- name: Check frontend copy-paste duplication
run: npm --prefix frontend-modern run lint:cpd
- name: Build verified frontend bundle
run: npm --prefix frontend-modern run build
- name: Upload verified frontend bundle
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: release-frontend-${{ github.sha }}
path: frontend-modern/dist/
if-no-files-found: error
retention-days: 1
compression-level: 0
overwrite: true
# Backend tests run in parallel with frontend checks
backend_tests:
needs: prepare
needs:
- prepare
- frontend_checks
if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' }}
runs-on: ubuntu-24.04
timeout-minutes: 30
@ -280,25 +275,11 @@ jobs:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
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@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
- name: Download verified frontend bundle
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
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: release-frontend-${{ github.sha }}
- name: Copy frontend to embed location
run: |
@ -508,7 +489,7 @@ jobs:
integration_tests:
needs:
- prepare
- backend_tests
- frontend_checks
if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' && needs.prepare.outputs.is_prerelease != 'true' }}
runs-on: ubuntu-24.04
timeout-minutes: 45
@ -525,11 +506,11 @@ jobs:
cache: 'npm'
cache-dependency-path: 'frontend-modern/package-lock.json'
- name: Restore frontend build cache
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
- name: Download verified frontend bundle
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
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: release-frontend-${{ github.sha }}
- name: Copy frontend to embed location
run: |
@ -661,13 +642,14 @@ jobs:
create_release:
needs:
- prepare
- build_release_candidate
- 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') }}
if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' && always() && needs.build_release_candidate.result == 'success' && 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:
@ -685,80 +667,25 @@ jobs:
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
- name: Download immutable release candidate
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
go-version-file: go.mod
cache: true
name: ${{ needs.build_release_candidate.outputs.artifact_name }}
path: release
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
- name: Download release candidate manifest
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: 'frontend-modern/package-lock.json'
name: ${{ needs.build_release_candidate.outputs.manifest_artifact_name }}
path: release-candidate-manifest
- name: Install dependencies
- name: Verify immutable release candidate
run: |
sudo apt-get update
sudo apt-get install -y zip
- name: Set up Helm
uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 # v5.0.0
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
python3 scripts/release_candidate_manifest.py verify-local \
--release-dir release \
--manifest release-candidate-manifest/release-candidate.json \
--version "${{ needs.prepare.outputs.version }}" \
--source-sha "${GITHUB_SHA}"
- name: Attest release assets
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
@ -1155,9 +1082,9 @@ jobs:
validate_release_assets:
needs:
- prepare
- build_release_candidate
- create_release
- publish_docker
if: ${{ always() && needs.prepare.result == 'success' && needs.create_release.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' && (github.event.inputs.draft_only == 'true' || needs.publish_docker.result == 'success') }}
if: ${{ always() && needs.prepare.result == 'success' && needs.build_release_candidate.result == 'success' && needs.create_release.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' }}
permissions:
contents: write
issues: write
@ -1170,6 +1097,7 @@ jobs:
release_id: ${{ needs.create_release.outputs.release_id }}
draft: ${{ github.event.inputs.draft_only == 'true' }}
target_commitish: ${{ needs.create_release.outputs.target_commitish }}
candidate_manifest_artifact: ${{ needs.build_release_candidate.outputs.manifest_artifact_name }}
# End-to-end install.sh smoke against the just-published release. Catches
# runtime regressions in the documented Proxmox-LXC / systemd install flow
@ -1243,15 +1171,15 @@ jobs:
# 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.
# validate_release_assets and publish_docker succeed 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
- publish_docker
- 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' }}
if: ${{ always() && needs.prepare.result == 'success' && needs.publish_docker.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
@ -1271,8 +1199,8 @@ jobs:
publish_private_pro_runtime:
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' && startsWith(needs.prepare.outputs.version, '6.') }}
- create_release
if: ${{ always() && needs.prepare.result == 'success' && needs.create_release.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' && github.event.inputs.draft_only != 'true' && startsWith(needs.prepare.outputs.version, '6.') }}
runs-on: ubuntu-24.04
timeout-minutes: 150
steps:
@ -1398,7 +1326,6 @@ jobs:
DRAFT_ONLY: ${{ github.event.inputs.draft_only }}
VERSION: ${{ needs.prepare.outputs.version }}
IS_PRERELEASE: ${{ needs.prepare.outputs.is_prerelease }}
PREFLIGHT_RUN_URL: ${{ needs.prepare.outputs.preflight_run_url }}
CREATE_RESULT: ${{ needs.create_release.result }}
DOCKER_RESULT: ${{ needs.publish_docker.result }}
VALIDATE_RESULT: ${{ needs.validate_release_assets.result }}
@ -1436,6 +1363,3 @@ jobs:
fi
echo "Release verdict passed for v${VERSION}."
if [ -n "${PREFLIGHT_RUN_URL:-}" ]; then
echo "Stable patch preflight: ${PREFLIGHT_RUN_URL}"
fi

View file

@ -59,6 +59,16 @@ permissions:
contents: read
jobs:
build_release_candidate:
name: Build Immutable Release Candidate
if: ${{ github.event_name == 'workflow_dispatch' }}
permissions:
contents: read
uses: ./.github/workflows/build-release-candidate.yml
secrets: inherit
with:
version: ${{ inputs.version }}
dry-run:
name: Preflight Release Checks (No Publish)
runs-on: ubuntu-24.04

View file

@ -23,6 +23,11 @@ on:
description: 'Commit SHA associated with the release'
required: true
type: string
candidate_manifest_artifact:
description: 'Same-run immutable candidate manifest artifact for fast digest verification'
required: false
default: ''
type: string
release:
types: [edited]
workflow_dispatch:
@ -47,6 +52,11 @@ on:
description: 'Commit SHA associated with the release'
required: true
type: string
candidate_manifest_artifact:
description: 'Optional immutable candidate manifest artifact name'
required: false
default: ''
type: string
permissions:
contents: read
@ -109,8 +119,31 @@ jobs:
cat context.env >> "$GITHUB_OUTPUT"
cat context.env
- name: Download all release assets
- name: Download immutable candidate manifest
if: steps.context.outputs.should_run == 'true' && inputs.candidate_manifest_artifact != ''
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: ${{ inputs.candidate_manifest_artifact }}
path: release-candidate-manifest
- name: Fetch release asset metadata
if: steps.context.outputs.should_run == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
gh api --paginate \
"repos/${{ github.repository }}/releases/${{ steps.context.outputs.release_id }}/assets?per_page=100" \
--slurp > "$RUNNER_TEMP/release-assets.json"
count="$(jq '[.[][]] | length' "$RUNNER_TEMP/release-assets.json")"
if [ "$count" -eq 0 ]; then
echo "::error::No assets found in release"
exit 1
fi
echo "Found ${count} published release assets."
- name: Download all release assets for legacy validation
if: steps.context.outputs.should_run == 'true' && inputs.candidate_manifest_artifact == ''
id: download
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@ -156,11 +189,11 @@ jobs:
ls -lh
- name: Install Docker
if: steps.context.outputs.should_run == 'true'
if: steps.context.outputs.should_run == 'true' && inputs.candidate_manifest_artifact == ''
run: docker --version
- name: Pull Docker image (with retry logic)
if: steps.context.outputs.should_run == 'true'
if: steps.context.outputs.should_run == 'true' && inputs.candidate_manifest_artifact == ''
id: docker
continue-on-error: true
run: |
@ -204,13 +237,21 @@ jobs:
- name: Run validation script
if: steps.context.outputs.should_run == 'true'
id: validate
env:
CANDIDATE_MANIFEST_ARTIFACT: ${{ inputs.candidate_manifest_artifact }}
run: |
set +e
echo "Running validation script..."
chmod +x scripts/validate-release.sh
OUTPUT_FILE=$(mktemp)
if [ "${{ steps.docker.outputs.image_available }}" = "true" ]; then
if [ -n "${CANDIDATE_MANIFEST_ARTIFACT}" ]; then
echo "Validating GitHub's stored SHA-256 digests against the immutable candidate manifest..."
python3 scripts/release_candidate_manifest.py verify-release \
--manifest release-candidate-manifest/release-candidate.json \
--assets-json "$RUNNER_TEMP/release-assets.json" \
--version "${{ steps.context.outputs.version }}" \
--source-sha "${{ steps.context.outputs.target_commitish }}" 2>&1 | tee "$OUTPUT_FILE"
elif [ "${{ steps.docker.outputs.image_available }}" = "true" ]; then
echo "Running full validation (Docker + assets)..."
scripts/validate-release.sh \
"${{ steps.context.outputs.version }}" \