mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
f61815839f
Change-source: pulse-maintainer
2221 lines
102 KiB
YAML
2221 lines
102 KiB
YAML
name: Pulse Release Pipeline
|
|
# Alpha and beta use the fast preview path. RCs run the stable-depth release
|
|
# checks because an RC is a build the maintainer believes can become stable.
|
|
|
|
on:
|
|
workflow_dispatch:
|
|
inputs:
|
|
version:
|
|
description: 'Version number (e.g., 4.30.0)'
|
|
required: true
|
|
type: string
|
|
release_notes:
|
|
description: 'Release notes (markdown)'
|
|
required: true
|
|
type: string
|
|
release_screenshot_plan:
|
|
description: 'Validated model-selected release-note visual plan (JSON)'
|
|
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
|
|
unsigned_windows_exception:
|
|
description: 'Optional version-bound override after SignPath availability is restored; not required while the standing unavailable policy is active'
|
|
required: false
|
|
type: boolean
|
|
default: false
|
|
unsigned_windows_reason:
|
|
description: 'Owner reason for an explicit version-bound unsigned Windows override'
|
|
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
|
|
mobile_release_decision:
|
|
description: 'Required mobile impact decision: no-mobile-impact, existing-mobile-build-compatible, mobile-candidate-uploaded, or mobile-candidate-required'
|
|
required: true
|
|
type: string
|
|
mobile_release_evidence:
|
|
description: 'Evidence for existing-mobile-build-compatible or mobile-candidate-uploaded decisions'
|
|
required: false
|
|
type: string
|
|
|
|
concurrency:
|
|
group: release-v${{ github.event.inputs.version || github.ref || github.run_id }}
|
|
cancel-in-progress: false
|
|
|
|
permissions:
|
|
actions: read
|
|
contents: read
|
|
|
|
jobs:
|
|
# Combined version extraction and validation (saves a checkout)
|
|
prepare:
|
|
# Stable releases use hosted runners regardless of their Windows-signing
|
|
# decision. Prereleases retain the credential-free PVE acceleration path.
|
|
runs-on: ${{ !contains(inputs.version, '-') && 'ubuntu-24.04' || fromJSON('["self-hosted","Linux","X64","pulse-pve-compile"]') }}
|
|
timeout-minutes: 5
|
|
outputs:
|
|
version: ${{ steps.extract.outputs.version }}
|
|
tag: ${{ steps.extract.outputs.tag }}
|
|
is_prerelease: ${{ steps.extract.outputs.is_prerelease }}
|
|
release_stage: ${{ steps.promotion.outputs.release_stage }}
|
|
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 }}
|
|
require_windows_signing: ${{ steps.promotion.outputs.require_windows_signing }}
|
|
unsigned_windows_exception: ${{ steps.promotion.outputs.unsigned_windows_exception }}
|
|
unsigned_windows_reason: ${{ steps.promotion.outputs.unsigned_windows_reason }}
|
|
promotion_mode: ${{ steps.promotion.outputs.promotion_mode }}
|
|
is_stable_patch: ${{ steps.promotion.outputs.is_stable_patch }}
|
|
historical_asset_backfill_only: ${{ steps.extract.outputs.historical_asset_backfill_only }}
|
|
visual_capture_count: ${{ steps.visual_plan.outputs.capture_count }}
|
|
visual_comparison_tag: ${{ steps.visual_plan.outputs.comparison_tag }}
|
|
steps:
|
|
- name: Checkout repository
|
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
with:
|
|
persist-credentials: false
|
|
fetch-depth: 0
|
|
|
|
- name: Extract version
|
|
id: extract
|
|
env:
|
|
VERSION_INPUT: ${{ inputs.version }}
|
|
HISTORICAL_ASSET_BACKFILL_INPUT: ${{ inputs.historical_asset_backfill_only }}
|
|
run: |
|
|
set -euo pipefail
|
|
if [[ ! "${VERSION_INPUT}" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-((rc|alpha|beta)\.[0-9]+))?$ ]]; then
|
|
echo "::error::workflow_dispatch must include an exact supported version"
|
|
exit 1
|
|
fi
|
|
if [[ "${HISTORICAL_ASSET_BACKFILL_INPUT}" != "true" && \
|
|
"${HISTORICAL_ASSET_BACKFILL_INPUT}" != "false" ]]; then
|
|
echo "::error::historical_asset_backfill_only must be true or false"
|
|
exit 1
|
|
fi
|
|
VERSION="${VERSION_INPUT}"
|
|
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="${HISTORICAL_ASSET_BACKFILL_INPUT}"
|
|
python3 scripts/write_github_output.py tag "${TAG}"
|
|
python3 scripts/write_github_output.py version "${VERSION}"
|
|
echo "is_prerelease=${IS_PRERELEASE}" >> $GITHUB_OUTPUT
|
|
echo "source_branch=${SOURCE_BRANCH}" >> $GITHUB_OUTPUT
|
|
python3 scripts/write_github_output.py historical_asset_backfill_only "${HISTORICAL_ASSET_BACKFILL_ONLY}"
|
|
echo "Version: ${VERSION}, Tag: ${TAG}, Prerelease: ${IS_PRERELEASE}, Branch: ${SOURCE_BRANCH}, HistoricalBackfillOnly: ${HISTORICAL_ASSET_BACKFILL_ONLY}"
|
|
|
|
- name: Resolve required release branch
|
|
id: branch_policy
|
|
env:
|
|
WORKFLOW_OUTPUT_1: ${{ steps.extract.outputs.version }}
|
|
WORKFLOW_OUTPUT_2: ${{ steps.extract.outputs.source_branch }}
|
|
run: |
|
|
REQUIRED_BRANCH="$(python3 scripts/release_control/control_plane.py --branch-for-version "${WORKFLOW_OUTPUT_1}")"
|
|
if [ "${WORKFLOW_OUTPUT_2}" != "$REQUIRED_BRANCH" ]; then
|
|
echo "::error::Invalid release line. Version ${WORKFLOW_OUTPUT_1} must run from ${REQUIRED_BRANCH}, but workflow ref is ${WORKFLOW_OUTPUT_2}."
|
|
exit 1
|
|
fi
|
|
python3 scripts/write_github_output.py required_branch "${REQUIRED_BRANCH}"
|
|
echo "[OK] Governed release branch for ${WORKFLOW_OUTPUT_1} is ${REQUIRED_BRANCH}"
|
|
|
|
- name: Validate release-note visual plan
|
|
id: visual_plan
|
|
env:
|
|
WORKFLOW_OUTPUT_1: ${{ steps.extract.outputs.historical_asset_backfill_only }}
|
|
WORKFLOW_OUTPUT_2: ${{ steps.extract.outputs.version }}
|
|
run: |
|
|
set -euo pipefail
|
|
PLAN_FILE=$(mktemp)
|
|
if ! jq -er '.inputs.release_screenshot_plan | select(type == "string" and length > 0)' \
|
|
"$GITHUB_EVENT_PATH" > "$PLAN_FILE"; then
|
|
echo "::error::release_screenshot_plan must contain an evidence-backed visual decision"
|
|
exit 1
|
|
fi
|
|
python3 scripts/release_control/release_note_visuals.py \
|
|
validate --plan "$PLAN_FILE" --output "$PLAN_FILE"
|
|
CAPTURE_COUNT=$(python3 scripts/release_control/release_note_visuals.py \
|
|
count --plan "$PLAN_FILE")
|
|
COMPARISON_TAG=""
|
|
if [ "$CAPTURE_COUNT" -gt 0 ] && \
|
|
[ "${WORKFLOW_OUTPUT_1}" != "true" ]; then
|
|
COMPARISON_TAG=$(./scripts/generate-release-notes.sh \
|
|
--resolve-base "${WORKFLOW_OUTPUT_2}")
|
|
fi
|
|
echo "capture_count=${CAPTURE_COUNT}" >> "$GITHUB_OUTPUT"
|
|
echo "comparison_tag=${COMPARISON_TAG}" >> "$GITHUB_OUTPUT"
|
|
echo "[OK] Release-note visual plan contains ${CAPTURE_COUNT} capture(s)"
|
|
|
|
- name: Validate VERSION file
|
|
if: ${{ steps.extract.outputs.historical_asset_backfill_only != 'true' }}
|
|
env:
|
|
WORKFLOW_OUTPUT_1: ${{ steps.extract.outputs.version }}
|
|
run: |
|
|
FILE_VERSION=$(cat VERSION | tr -d '\n')
|
|
REQUESTED_VERSION="${WORKFLOW_OUTPUT_1}"
|
|
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 mobile release decision
|
|
if: ${{ steps.extract.outputs.historical_asset_backfill_only != 'true' }}
|
|
env:
|
|
MOBILE_RELEASE_DECISION: ${{ github.event.inputs.mobile_release_decision }}
|
|
MOBILE_RELEASE_EVIDENCE: ${{ github.event.inputs.mobile_release_evidence }}
|
|
WORKFLOW_OUTPUT_1: ${{ steps.extract.outputs.version }}
|
|
run: |
|
|
set -euo pipefail
|
|
python3 scripts/release_control/mobile_release_gate.py \
|
|
--version "${WORKFLOW_OUTPUT_1}" \
|
|
--decision "${MOBILE_RELEASE_DECISION}" \
|
|
--evidence "${MOBILE_RELEASE_EVIDENCE}" \
|
|
--github-annotations
|
|
|
|
- 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 }}
|
|
UNSIGNED_WINDOWS_EXCEPTION_INPUT: ${{ github.event.inputs.unsigned_windows_exception }}
|
|
UNSIGNED_WINDOWS_REASON_INPUT: ${{ github.event.inputs.unsigned_windows_reason }}
|
|
DRAFT_ONLY_INPUT: ${{ github.event.inputs.draft_only }}
|
|
GH_TOKEN: ${{ github.token }}
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
git fetch --prune origin main "${REQUIRED_BRANCH}" --tags
|
|
|
|
NOTES_FILE="$(mktemp)"
|
|
if ! jq -er '.inputs.release_notes | select(type == "string" and length > 0)' \
|
|
"$GITHUB_EVENT_PATH" > "$NOTES_FILE"; then
|
|
echo "::error::release_notes must be a non-empty Markdown string"
|
|
exit 1
|
|
fi
|
|
|
|
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
|
|
if [ "${UNSIGNED_WINDOWS_EXCEPTION_INPUT:-false}" = "true" ]; then
|
|
HELPER_ARGS+=(
|
|
--unsigned-windows-exception
|
|
--unsigned-windows-reason "${UNSIGNED_WINDOWS_REASON_INPUT:-}"
|
|
)
|
|
elif [ -n "${UNSIGNED_WINDOWS_REASON_INPUT:-}" ]; then
|
|
HELPER_ARGS+=(--unsigned-windows-reason "${UNSIGNED_WINDOWS_REASON_INPUT}")
|
|
fi
|
|
if [ "${DRAFT_ONLY_INPUT:-false}" != "true" ]; then
|
|
HELPER_ARGS+=(--enforce-prerelease-observation-window)
|
|
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}"
|
|
|
|
# Repository release immutability is configuration outside this commit. Prove
|
|
# that prerequisite on a GitHub-hosted runner before starting compilation,
|
|
# signing, private staging, or draft assembly. Activation repeats the same
|
|
# check immediately before publication so later setting drift still fails
|
|
# closed. Inert draft-only and historical-backfill runs do not publish and
|
|
# therefore do not require this repository setting.
|
|
publication_trust_preflight:
|
|
name: Publication Trust Preflight
|
|
needs: prepare
|
|
runs-on: ubuntu-24.04
|
|
timeout-minutes: 5
|
|
steps:
|
|
- name: Checkout release trust control
|
|
if: ${{ github.event.inputs.draft_only != 'true' && needs.prepare.outputs.historical_asset_backfill_only != 'true' }}
|
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
with:
|
|
persist-credentials: false
|
|
|
|
- name: Require immutable release publication capability
|
|
if: ${{ github.event.inputs.draft_only != 'true' && needs.prepare.outputs.historical_asset_backfill_only != 'true' }}
|
|
env:
|
|
GH_TOKEN: ${{ secrets.WORKFLOW_PAT }}
|
|
run: |
|
|
set -euo pipefail
|
|
if [ -z "${GH_TOKEN:-}" ]; then
|
|
echo "::error::WORKFLOW_PAT with repository Administration (read) is required to prove release immutability."
|
|
exit 1
|
|
fi
|
|
./scripts/check-github-release-immutability.sh "${GITHUB_REPOSITORY}"
|
|
|
|
- name: Confirm inert release mode
|
|
if: ${{ github.event.inputs.draft_only == 'true' || needs.prepare.outputs.historical_asset_backfill_only == 'true' }}
|
|
run: echo "Publication trust preflight is not required for an inert draft-only or historical-backfill run."
|
|
|
|
build_release_candidate:
|
|
name: Build Immutable Release Candidate
|
|
needs:
|
|
- prepare
|
|
- publication_trust_preflight
|
|
if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' }}
|
|
permissions:
|
|
actions: write
|
|
attestations: write
|
|
contents: read
|
|
id-token: write
|
|
uses: ./.github/workflows/build-release-candidate.yml
|
|
secrets: inherit
|
|
with:
|
|
version: ${{ needs.prepare.outputs.version }}
|
|
qualify_containers: false
|
|
require_macos_signing: true
|
|
require_windows_signing: ${{ needs.prepare.outputs.require_windows_signing == 'true' }}
|
|
windows_signing_backend: signpath
|
|
|
|
qualify_release_containers:
|
|
name: Qualify Exact-Candidate Containers
|
|
needs:
|
|
- prepare
|
|
- build_release_candidate
|
|
if: ${{ always() && needs.prepare.result == 'success' && needs.build_release_candidate.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' }}
|
|
permissions:
|
|
contents: read
|
|
uses: ./.github/workflows/qualify-release-containers.yml
|
|
with:
|
|
version: ${{ needs.prepare.outputs.version }}
|
|
container_artifact: ${{ needs.build_release_candidate.outputs.container_artifact_name }}
|
|
|
|
# Build the embed bundle independently so backend and smoke lanes can start
|
|
# without waiting for the full frontend quality suite.
|
|
frontend_bundle:
|
|
needs:
|
|
- prepare
|
|
- publication_trust_preflight
|
|
if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' }}
|
|
runs-on: ${{ !contains(inputs.version, '-') && 'ubuntu-24.04' || fromJSON('["self-hosted","Linux","X64","pulse-pve-build"]') }}
|
|
timeout-minutes: 10
|
|
steps:
|
|
- name: Checkout repository
|
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
with:
|
|
persist-credentials: false
|
|
|
|
- name: Set up Node.js
|
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
|
with:
|
|
node-version: '24'
|
|
|
|
- name: Install dependencies
|
|
run: npm --prefix frontend-modern ci
|
|
|
|
- name: Build frontend bundle
|
|
run: npm --prefix frontend-modern run build
|
|
|
|
- name: Upload 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
|
|
|
|
# Frontend checks run independently from the bundle and backend lanes.
|
|
frontend_checks:
|
|
needs:
|
|
- prepare
|
|
- publication_trust_preflight
|
|
if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' }}
|
|
runs-on: ubuntu-24.04
|
|
timeout-minutes: 20
|
|
steps:
|
|
- name: Checkout repository
|
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
with:
|
|
persist-credentials: false
|
|
|
|
- name: Set up Node.js
|
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
|
with:
|
|
node-version: '24'
|
|
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
|
|
|
|
- name: Type-check frontend
|
|
run: npm --prefix frontend-modern run type-check
|
|
|
|
- name: Test frontend
|
|
run: npm --prefix frontend-modern test
|
|
|
|
windows_install_command_smoke:
|
|
name: Windows PowerShell 5.1 Install Command Smoke
|
|
needs:
|
|
- prepare
|
|
- publication_trust_preflight
|
|
if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' }}
|
|
runs-on: windows-2025
|
|
timeout-minutes: 15
|
|
steps:
|
|
- name: Checkout repository
|
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
with:
|
|
persist-credentials: false
|
|
|
|
- name: Set up Node.js
|
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
|
with:
|
|
node-version: '24'
|
|
cache: 'npm'
|
|
cache-dependency-path: 'frontend-modern/package-lock.json'
|
|
|
|
- name: Install frontend test dependencies
|
|
working-directory: frontend-modern
|
|
run: npm ci
|
|
|
|
- name: Execute generated command with Windows PowerShell 5.1
|
|
working-directory: frontend-modern
|
|
run: npm test -- --run src/utils/__tests__/agentInstallCommand.windows.test.ts
|
|
|
|
# The dedicated PVE test runner provides the memory needed to run two
|
|
# complete, disjoint internal/api shards while all other packages run in a
|
|
# third lane. It holds no signing or publication credentials.
|
|
backend_tests:
|
|
needs:
|
|
- prepare
|
|
- frontend_bundle
|
|
if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' }}
|
|
runs-on: ${{ !contains(inputs.version, '-') && 'ubuntu-24.04' || fromJSON('["self-hosted","Linux","X64","pulse-pve-tests"]') }}
|
|
# The rc.9 race-enabled API shards consumed more than 18 minutes on the PVE
|
|
# runner before post-step accounting. Keep the outer job above the canonical
|
|
# 45-minute API watchdog so checkout, bundle transfer, shard planning, and
|
|
# cleanup cannot pre-empt the process that owns stuck-package detection.
|
|
# Stable v6.4.2 rehearsal 33417470872 completed all three API shards in
|
|
# 32 minutes, then exhausted the former 55-minute ceiling while the
|
|
# independently bounded non-API graph was still passing packages. Keep
|
|
# every inner watchdog unchanged and leave enough outer cleanup headroom
|
|
# for the expanded secure-runtime install tests on a cold hosted worker.
|
|
timeout-minutes: 70
|
|
env:
|
|
FRONTEND_DIST: frontend-modern/dist
|
|
steps:
|
|
- name: Checkout repository
|
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
with:
|
|
persist-credentials: false
|
|
|
|
- name: Download verified frontend bundle
|
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
with:
|
|
path: frontend-modern/dist
|
|
name: release-frontend-${{ github.sha }}
|
|
|
|
- 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@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
|
with:
|
|
go-version-file: go.mod
|
|
cache: false
|
|
|
|
- name: Run backend tests
|
|
run: ./scripts/run-release-backend-tests.sh --data-root "$RUNNER_TEMP/pulse-test-data"
|
|
|
|
|
|
# Alpha and beta builds are feedback checkpoints. RC and stable publication
|
|
# run the deeper integration gate because an RC must be promotable in intent.
|
|
integration_tests:
|
|
needs:
|
|
- prepare
|
|
- frontend_bundle
|
|
if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' && needs.prepare.outputs.release_stage != 'alpha' && needs.prepare.outputs.release_stage != 'beta' }}
|
|
runs-on: ubuntu-24.04
|
|
timeout-minutes: 45
|
|
env:
|
|
FRONTEND_DIST: frontend-modern/dist
|
|
steps:
|
|
- name: Checkout repository
|
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
with:
|
|
persist-credentials: false
|
|
|
|
- name: Set up Node.js
|
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
|
with:
|
|
node-version: '24'
|
|
cache: 'npm'
|
|
cache-dependency-path: 'frontend-modern/package-lock.json'
|
|
|
|
- name: Download verified frontend bundle
|
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
with:
|
|
path: frontend-modern/dist
|
|
name: release-frontend-${{ github.sha }}
|
|
|
|
- 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@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
|
with:
|
|
go-version-file: go.mod
|
|
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 "Validating seeded bootstrap token..."
|
|
BOOTSTRAP_STATUS=$(curl -s -o /tmp/bootstrap-token-validation.txt -w "%{http_code}" \
|
|
-X POST \
|
|
-H "Content-Type: application/json" \
|
|
--data "{\"token\":\"${PULSE_E2E_BOOTSTRAP_TOKEN}\"}" \
|
|
http://localhost:7655/api/security/validate-bootstrap-token || true)
|
|
echo "Bootstrap token validation endpoint returned HTTP ${BOOTSTRAP_STATUS}"
|
|
if [ "${BOOTSTRAP_STATUS}" != "204" ]; then
|
|
cat /tmp/bootstrap-token-validation.txt || true
|
|
docker logs pulse-test-server || true
|
|
exit 1
|
|
fi
|
|
|
|
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 current organization-sharing E2E suite..."
|
|
npx playwright test \
|
|
tests/66-organization-sharing-approval-ui.spec.ts \
|
|
--project=chromium \
|
|
--reporter=list
|
|
|
|
docker compose -f docker-compose.test.yml down -v
|
|
|
|
- name: Collect integration diagnostics
|
|
if: failure()
|
|
working-directory: tests/integration
|
|
run: |
|
|
mkdir -p release-integration-diagnostics
|
|
{
|
|
echo "=== Docker containers ==="
|
|
docker ps -a || true
|
|
echo
|
|
echo "=== Pulse test server logs ==="
|
|
docker logs pulse-test-server 2>&1 || echo "No pulse-test-server container"
|
|
echo
|
|
echo "=== Mock GitHub server logs ==="
|
|
docker logs pulse-mock-github 2>&1 || echo "No pulse-mock-github container"
|
|
} | tee release-integration-diagnostics/docker.log
|
|
|
|
- name: Upload integration Playwright report
|
|
if: failure()
|
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
with:
|
|
name: release-integration-playwright-report
|
|
path: tests/integration/playwright-report/
|
|
if-no-files-found: ignore
|
|
retention-days: 14
|
|
|
|
- name: Upload integration failures
|
|
if: failure()
|
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
with:
|
|
name: release-integration-failures
|
|
path: |
|
|
tests/integration/test-results/
|
|
tests/integration/release-integration-diagnostics/
|
|
if-no-files-found: ignore
|
|
retention-days: 14
|
|
|
|
- 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
|
|
# Release smoke: render-level assertions on the primary surfaces (Proxmox,
|
|
# Docker, Kubernetes, Alert thresholds), run for EVERY cut including
|
|
# prereleases. integration_tests stays stable-only for depth; this job
|
|
# exists because v6.2.0-rc.5 shipped with its primary surfaces broken while
|
|
# the only coverage lived in non-gating CI tiers (#1663). A prerelease is
|
|
# the build users test — it must never skip the "do the pages render data"
|
|
# bar.
|
|
release_smoke:
|
|
needs:
|
|
- prepare
|
|
- frontend_bundle
|
|
if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' }}
|
|
runs-on: ubuntu-24.04
|
|
timeout-minutes: 30
|
|
env:
|
|
PULSE_E2E_BOOTSTRAP_TOKEN: 0123456789abcdef0123456789abcdef0123456789abcdef
|
|
steps:
|
|
- name: Checkout repository
|
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
with:
|
|
persist-credentials: false
|
|
|
|
- name: Set up Node.js
|
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
|
with:
|
|
node-version: '24'
|
|
cache: 'npm'
|
|
cache-dependency-path: 'frontend-modern/package-lock.json'
|
|
|
|
- name: Download verified frontend bundle
|
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
with:
|
|
path: frontend-modern/dist
|
|
name: release-frontend-${{ github.sha }}
|
|
|
|
- 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: Build Docker image for the smoke environment
|
|
# GO_BUILD_TAGS="" drops the release build tag so mock fixtures are
|
|
# available; the release-tagged binary itself is covered by
|
|
# backend_tests and build_release_candidate.
|
|
run: |
|
|
docker build -t pulse:test --target e2e_runtime --build-arg GO_BUILD_TAGS="" .
|
|
docker build -t pulse-mock-github:test tests/integration/mock-github-server
|
|
env:
|
|
PULSE_LICENSE_PUBLIC_KEY: ${{ secrets.PULSE_LICENSE_PUBLIC_KEY }}
|
|
|
|
- name: Install Playwright
|
|
working-directory: tests/integration
|
|
run: |
|
|
npm ci
|
|
npx playwright install --with-deps chromium
|
|
|
|
- name: Run release smoke
|
|
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
|
|
|
|
npx playwright test tests/95-release-smoke.spec.ts \
|
|
--project=chromium \
|
|
--reporter=list
|
|
|
|
docker compose -f docker-compose.test.yml down -v
|
|
|
|
- name: Collect smoke diagnostics
|
|
if: failure()
|
|
working-directory: tests/integration
|
|
run: |
|
|
docker logs pulse-test-server || true
|
|
docker compose -f docker-compose.test.yml down -v || true
|
|
|
|
- name: Upload smoke diagnostics
|
|
if: failure()
|
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
with:
|
|
name: release-smoke-failures-${{ github.sha }}
|
|
path: |
|
|
tests/integration/test-results/
|
|
tests/integration/playwright-report/
|
|
if-no-files-found: ignore
|
|
retention-days: 14
|
|
|
|
release_note_visuals:
|
|
needs:
|
|
- prepare
|
|
- publication_trust_preflight
|
|
if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' }}
|
|
runs-on: ubuntu-24.04
|
|
timeout-minutes: 45
|
|
permissions:
|
|
contents: read
|
|
steps:
|
|
- name: Checkout repository
|
|
if: ${{ needs.prepare.outputs.visual_capture_count != '0' }}
|
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
with:
|
|
persist-credentials: false
|
|
fetch-depth: 0
|
|
|
|
- name: Install browser capture runtime
|
|
if: ${{ needs.prepare.outputs.visual_capture_count != '0' }}
|
|
run: |
|
|
npm ci --ignore-scripts
|
|
npx playwright install --with-deps chromium
|
|
|
|
- name: Capture comparison and candidate views
|
|
if: ${{ needs.prepare.outputs.visual_capture_count != '0' }}
|
|
env:
|
|
WORKFLOW_OUTPUT_1: ${{ needs.prepare.outputs.visual_comparison_tag }}
|
|
run: |
|
|
set -euo pipefail
|
|
PLAN_FILE=$(mktemp)
|
|
jq -er '.inputs.release_screenshot_plan' "$GITHUB_EVENT_PATH" > "$PLAN_FILE"
|
|
bash scripts/capture-release-note-visuals.sh \
|
|
"$PLAN_FILE" \
|
|
"${WORKFLOW_OUTPUT_1}" \
|
|
release-note-visuals
|
|
|
|
- name: Upload release-note visual artifact
|
|
if: ${{ needs.prepare.outputs.visual_capture_count != '0' }}
|
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
with:
|
|
name: release-note-visuals-${{ github.sha }}
|
|
path: release-note-visuals/*.png
|
|
if-no-files-found: error
|
|
retention-days: 14
|
|
|
|
create_release:
|
|
needs:
|
|
- prepare
|
|
- build_release_candidate
|
|
- release_note_visuals
|
|
# Draft metadata and immutable assets are inert staging. Qualification is
|
|
# joined at release_readiness before any activation boundary can open.
|
|
if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' && always() && needs.prepare.result == 'success' && needs.build_release_candidate.result == 'success' && needs.release_note_visuals.result == 'success' }}
|
|
runs-on: ubuntu-24.04
|
|
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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
with:
|
|
persist-credentials: true # required: authenticated git writes
|
|
fetch-depth: 0
|
|
|
|
- name: Download immutable release candidate
|
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
with:
|
|
name: ${{ needs.build_release_candidate.outputs.artifact_name }}
|
|
path: release
|
|
|
|
- name: Download release candidate manifest
|
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
with:
|
|
name: ${{ needs.build_release_candidate.outputs.manifest_artifact_name }}
|
|
path: release-candidate-manifest
|
|
|
|
- name: Download release-note visuals
|
|
if: ${{ needs.prepare.outputs.visual_capture_count != '0' }}
|
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
with:
|
|
name: release-note-visuals-${{ github.sha }}
|
|
path: release-note-visuals
|
|
|
|
- name: Verify immutable release candidate
|
|
env:
|
|
WORKFLOW_OUTPUT_1: ${{ needs.prepare.outputs.version }}
|
|
run: |
|
|
python3 scripts/release_candidate_manifest.py verify-local \
|
|
--release-dir release \
|
|
--manifest release-candidate-manifest/release-candidate.json \
|
|
--version "${WORKFLOW_OUTPUT_1}" \
|
|
--source-sha "${GITHUB_SHA}"
|
|
|
|
- name: Prepare release notes
|
|
id: generate_notes
|
|
env:
|
|
WORKFLOW_OUTPUT_1: ${{ needs.prepare.outputs.version }}
|
|
WORKFLOW_OUTPUT_2: ${{ needs.prepare.outputs.tag }}
|
|
WORKFLOW_OUTPUT_3: ${{ needs.prepare.outputs.release_stage }}
|
|
WORKFLOW_OUTPUT_4: ${{ needs.prepare.outputs.promoted_from_tag }}
|
|
WORKFLOW_OUTPUT_5: ${{ needs.prepare.outputs.rollback_tag }}
|
|
WORKFLOW_OUTPUT_6: ${{ needs.prepare.outputs.rollback_command }}
|
|
WORKFLOW_OUTPUT_7: ${{ needs.prepare.outputs.ga_date }}
|
|
WORKFLOW_OUTPUT_8: ${{ needs.prepare.outputs.v5_eos_date }}
|
|
WORKFLOW_OUTPUT_9: ${{ needs.prepare.outputs.hotfix_exception }}
|
|
WORKFLOW_OUTPUT_10: ${{ needs.prepare.outputs.hotfix_reason }}
|
|
WORKFLOW_OUTPUT_11: ${{ needs.prepare.outputs.require_windows_signing }}
|
|
WORKFLOW_OUTPUT_12: ${{ needs.prepare.outputs.unsigned_windows_exception }}
|
|
WORKFLOW_OUTPUT_13: ${{ needs.prepare.outputs.unsigned_windows_reason }}
|
|
run: |
|
|
set -euo pipefail
|
|
VERSION="${WORKFLOW_OUTPUT_1}"
|
|
NOTES_FILE=$(mktemp)
|
|
if ! jq -er '.inputs.release_notes | select(type == "string" and length > 0)' \
|
|
"$GITHUB_EVENT_PATH" > "$NOTES_FILE"; then
|
|
echo "::error::release_notes must be a non-empty Markdown string"
|
|
exit 1
|
|
fi
|
|
|
|
RENDERED_NOTES_FILE=$(mktemp)
|
|
VISUAL_PLAN_FILE=$(mktemp)
|
|
VISUAL_MARKDOWN_FILE=$(mktemp)
|
|
if ! jq -er '.inputs.release_screenshot_plan | select(type == "string" and length > 0)' \
|
|
"$GITHUB_EVENT_PATH" > "$VISUAL_PLAN_FILE"; then
|
|
echo "::error::release_screenshot_plan must contain an evidence-backed visual decision"
|
|
exit 1
|
|
fi
|
|
python3 scripts/release_control/release_note_visuals.py render \
|
|
--plan "$VISUAL_PLAN_FILE" \
|
|
--repository "${{ github.repository }}" \
|
|
--tag "${WORKFLOW_OUTPUT_2}" \
|
|
--output "$VISUAL_MARKDOWN_FILE"
|
|
python3 scripts/release_control/render_release_body.py \
|
|
--version "$VERSION" \
|
|
--release-notes-file "$NOTES_FILE" \
|
|
--release-visuals-file "$VISUAL_MARKDOWN_FILE" \
|
|
--output "$RENDERED_NOTES_FILE" \
|
|
--promotion-channel "${WORKFLOW_OUTPUT_3}" \
|
|
--candidate-tag "${WORKFLOW_OUTPUT_2}" \
|
|
--promoted-prerelease-tag "${WORKFLOW_OUTPUT_4}" \
|
|
--rollback-target "${WORKFLOW_OUTPUT_5}" \
|
|
--rollback-command "${WORKFLOW_OUTPUT_6}" \
|
|
--planned-ga-date "${WORKFLOW_OUTPUT_7}" \
|
|
--planned-v5-eos-date "${WORKFLOW_OUTPUT_8}" \
|
|
--hotfix-exception "${WORKFLOW_OUTPUT_9}" \
|
|
--hotfix-reason "${WORKFLOW_OUTPUT_10}" \
|
|
--require-windows-signing "${WORKFLOW_OUTPUT_11}" \
|
|
--unsigned-windows-exception "${WORKFLOW_OUTPUT_12}" \
|
|
--unsigned-windows-reason "${WORKFLOW_OUTPUT_13}"
|
|
|
|
# Customer-facing improvements provide the compact pre-update preview.
|
|
# Historical Highlights sections remain supported for older packets.
|
|
if grep -qiE "^#{1,6}[[:space:]]+(highlights|what.?s improved)\\b" "$RENDERED_NOTES_FILE"; then
|
|
echo "::notice::Release notes include customer-facing improvements — the update banner can preview them before users update."
|
|
else
|
|
echo "::notice::Release notes have no customer-facing improvements — the update banner will not show a summary preview."
|
|
fi
|
|
|
|
if grep -qiE "^#{1,6}[[:space:]]+(what.?s improved|added|new features|improved|improvements|changed|fixed|fixes|bug fixes|security|breaking changes|deprecated|removed)[[:space:]]*$" "$RENDERED_NOTES_FILE"; then
|
|
echo "::notice::Release notes include categorized changes — the post-update changelog dialog will show them."
|
|
else
|
|
echo "::notice::Release notes have no categorized changes — the post-update changelog dialog stays silent."
|
|
fi
|
|
|
|
echo "notes_file=${RENDERED_NOTES_FILE}" >> $GITHUB_OUTPUT
|
|
|
|
- name: Locate existing release
|
|
id: existing_release
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
WORKFLOW_OUTPUT_1: ${{ needs.prepare.outputs.tag }}
|
|
run: |
|
|
TAG="${WORKFLOW_OUTPUT_1}"
|
|
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')
|
|
RELEASE_ACTIVATION_COMMITTED=$(echo "$EXISTING_RELEASE" | jq -r 'any(.assets[]?; .name == "release-activation.json")')
|
|
|
|
python3 scripts/write_github_output.py release_id "${RELEASE_ID}"
|
|
python3 scripts/write_github_output.py release_url "${RELEASE_URL}"
|
|
python3 scripts/write_github_output.py release_is_draft "${RELEASE_IS_DRAFT}"
|
|
python3 scripts/write_github_output.py release_published_at "${RELEASE_PUBLISHED_AT}"
|
|
python3 scripts/write_github_output.py release_activation_committed "${RELEASE_ACTIVATION_COMMITTED}"
|
|
|
|
- name: Create tag
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
WORKFLOW_OUTPUT_1: ${{ needs.prepare.outputs.tag }}
|
|
WORKFLOW_OUTPUT_2: ${{ steps.existing_release.outputs.release_id }}
|
|
WORKFLOW_OUTPUT_3: ${{ steps.existing_release.outputs.release_is_draft }}
|
|
WORKFLOW_OUTPUT_4: ${{ steps.existing_release.outputs.release_published_at }}
|
|
WORKFLOW_OUTPUT_5: ${{ steps.existing_release.outputs.release_activation_committed }}
|
|
run: |
|
|
TAG="${WORKFLOW_OUTPUT_1}"
|
|
HEAD_SHA=$(git rev-parse HEAD)
|
|
EXISTING_RELEASE_ID="${WORKFLOW_OUTPUT_2}"
|
|
EXISTING_RELEASE_DRAFT="${WORKFLOW_OUTPUT_3}"
|
|
EXISTING_RELEASE_PUBLISHED_AT="${WORKFLOW_OUTPUT_4}"
|
|
EXISTING_RELEASE_ACTIVATION_COMMITTED="${WORKFLOW_OUTPUT_5}"
|
|
|
|
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" ] && [ "$EXISTING_RELEASE_ACTIVATION_COMMITTED" != "true" ]; then
|
|
if [ -n "$EXISTING_RELEASE_PUBLISHED_AT" ]; then
|
|
echo "Resuming quarantined draft for ${TAG}; GitHub retained historical published_at=${EXISTING_RELEASE_PUBLISHED_AT}."
|
|
fi
|
|
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 }}
|
|
WORKFLOW_OUTPUT_1: ${{ needs.prepare.outputs.tag }}
|
|
WORKFLOW_OUTPUT_2: ${{ steps.generate_notes.outputs.notes_file }}
|
|
WORKFLOW_OUTPUT_3: ${{ needs.prepare.outputs.is_prerelease }}
|
|
WORKFLOW_OUTPUT_4: ${{ steps.existing_release.outputs.release_id }}
|
|
WORKFLOW_OUTPUT_5: ${{ steps.existing_release.outputs.release_url }}
|
|
WORKFLOW_OUTPUT_6: ${{ steps.existing_release.outputs.release_is_draft }}
|
|
WORKFLOW_OUTPUT_7: ${{ steps.existing_release.outputs.release_published_at }}
|
|
WORKFLOW_OUTPUT_8: ${{ steps.existing_release.outputs.release_activation_committed }}
|
|
WORKFLOW_OUTPUT_9: ${{ needs.prepare.outputs.version }}
|
|
run: |
|
|
set -euo pipefail
|
|
TAG="${WORKFLOW_OUTPUT_1}"
|
|
NOTES_FILE="${WORKFLOW_OUTPUT_2}"
|
|
IS_PRERELEASE="${WORKFLOW_OUTPUT_3}"
|
|
HEAD_SHA=$(git rev-parse HEAD)
|
|
|
|
RELEASE_ID="${WORKFLOW_OUTPUT_4}"
|
|
RELEASE_URL="${WORKFLOW_OUTPUT_5}"
|
|
IS_DRAFT="${WORKFLOW_OUTPUT_6}"
|
|
PUBLISHED_AT="${WORKFLOW_OUTPUT_7}"
|
|
ACTIVATION_COMMITTED="${WORKFLOW_OUTPUT_8}"
|
|
RELEASE_PAYLOAD=$(mktemp)
|
|
RELEASE_JSON_FILE=$(mktemp)
|
|
ACTUAL_BODY_FILE=$(mktemp)
|
|
|
|
jq -n \
|
|
--arg tag_name "$TAG" \
|
|
--arg target_commitish "$HEAD_SHA" \
|
|
--arg name "Pulse ${TAG}" \
|
|
--rawfile body "$NOTES_FILE" \
|
|
--argjson draft true \
|
|
--argjson prerelease "$IS_PRERELEASE" \
|
|
'{
|
|
tag_name: $tag_name,
|
|
target_commitish: $target_commitish,
|
|
name: $name,
|
|
body: $body,
|
|
draft: $draft,
|
|
prerelease: $prerelease
|
|
}' > "$RELEASE_PAYLOAD"
|
|
|
|
if [ -n "$RELEASE_ID" ]; then
|
|
if [ "$IS_DRAFT" = "true" ] && [ "$ACTIVATION_COMMITTED" != "true" ]; then
|
|
if [ -n "$PUBLISHED_AT" ]; then
|
|
echo "Resuming quarantined draft release for ${TAG}; GitHub retained historical published_at=${PUBLISHED_AT}."
|
|
fi
|
|
echo "Updating existing draft release for ${TAG}"
|
|
gh api "repos/${{ github.repository }}/releases/${RELEASE_ID}" \
|
|
-X PATCH \
|
|
--input "$RELEASE_PAYLOAD" > "$RELEASE_JSON_FILE"
|
|
else
|
|
echo "::error::Published release already exists for ${TAG}."
|
|
exit 1
|
|
fi
|
|
else
|
|
echo "Creating draft release for ${TAG}..."
|
|
gh api "repos/${{ github.repository }}/releases" \
|
|
-X POST \
|
|
--input "$RELEASE_PAYLOAD" > "$RELEASE_JSON_FILE"
|
|
|
|
RELEASE_ID=$(jq -r '.id' "$RELEASE_JSON_FILE")
|
|
RELEASE_URL=$(jq -r '.html_url' "$RELEASE_JSON_FILE")
|
|
fi
|
|
|
|
gh api "repos/${{ github.repository }}/releases/${RELEASE_ID}" > "$RELEASE_JSON_FILE"
|
|
ACTUAL_RELEASE_TAG=$(jq -r '.tag_name // empty' "$RELEASE_JSON_FILE")
|
|
ACTUAL_TARGET_COMMITISH=$(jq -r '.target_commitish // empty' "$RELEASE_JSON_FILE")
|
|
RELEASE_URL=$(jq -r '.html_url' "$RELEASE_JSON_FILE")
|
|
jq -r '.body // ""' "$RELEASE_JSON_FILE" > "$ACTUAL_BODY_FILE"
|
|
|
|
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
|
|
|
|
python3 scripts/release_control/render_release_body.py \
|
|
--version "${WORKFLOW_OUTPUT_9}" \
|
|
--validate-body-file "$ACTUAL_BODY_FILE" \
|
|
--expected-body-file "$NOTES_FILE"
|
|
|
|
rm -f "$NOTES_FILE" "$RELEASE_PAYLOAD" "$RELEASE_JSON_FILE" "$ACTUAL_BODY_FILE"
|
|
|
|
echo "release_url=${RELEASE_URL}" >> $GITHUB_OUTPUT
|
|
python3 scripts/write_github_output.py release_id "${RELEASE_ID}"
|
|
echo "[OK] Draft release: ${TAG} (ID: ${RELEASE_ID})"
|
|
|
|
- name: Upload checksums
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
WORKFLOW_OUTPUT_1: ${{ needs.prepare.outputs.tag }}
|
|
run: |
|
|
TAG="${WORKFLOW_OUTPUT_1}"
|
|
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-note visuals
|
|
if: ${{ needs.prepare.outputs.visual_capture_count != '0' }}
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
WORKFLOW_OUTPUT_1: ${{ needs.prepare.outputs.tag }}
|
|
run: |
|
|
set -euo pipefail
|
|
TAG="${WORKFLOW_OUTPUT_1}"
|
|
PLAN_FILE=$(mktemp)
|
|
jq -er '.inputs.release_screenshot_plan' "$GITHUB_EVENT_PATH" > "$PLAN_FILE"
|
|
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
|
|
sleep "$wait_seconds"
|
|
attempt=$((attempt + 1))
|
|
wait_seconds=$((wait_seconds * 2))
|
|
if [ "$wait_seconds" -gt 120 ]; then
|
|
wait_seconds=120
|
|
fi
|
|
done
|
|
}
|
|
while IFS= read -r asset_name; do
|
|
test -f "release-note-visuals/${asset_name}"
|
|
release_upload_with_retry "$TAG" "release-note-visuals/${asset_name}" --clobber
|
|
done < <(python3 scripts/release_control/release_note_visuals.py \
|
|
assets --plan "$PLAN_FILE")
|
|
|
|
- name: Upload release assets
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
WORKFLOW_OUTPUT_1: ${{ needs.prepare.outputs.tag }}
|
|
run: |
|
|
TAG="${WORKFLOW_OUTPUT_1}"
|
|
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
|
|
release_upload_with_retry \
|
|
"${TAG}" \
|
|
release/release-build-provenance.sigstore.json \
|
|
--clobber
|
|
release_upload_with_retry \
|
|
"${TAG}" \
|
|
release/secure-runtime-build-contract-v1.json \
|
|
release/secure-runtime-compiler-provenance.sigstore.json \
|
|
release/pulse-secure-runtime-collector-v1-linux-amd64 \
|
|
release/pulse-secure-runtime-collector-v2-linux-amd64 \
|
|
release/pulse-secure-runtime-collector-v3-linux-amd64 \
|
|
--clobber
|
|
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-helper-linux-amd64 \
|
|
release/pulse-agent-helper-linux-arm64 \
|
|
release/pulse-agent-helper-linux-armv7 \
|
|
release/pulse-agent-helper-linux-armv6 \
|
|
release/pulse-agent-helper-linux-386 \
|
|
release/pulse-agent-runner-linux-amd64 \
|
|
release/pulse-agent-runner-linux-arm64 \
|
|
release/pulse-agent-runner-linux-armv7 \
|
|
release/pulse-agent-runner-linux-armv6 \
|
|
release/pulse-agent-runner-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: Stop after staging (draft only)
|
|
if: ${{ github.event.inputs.draft_only == 'true' }}
|
|
env:
|
|
WORKFLOW_OUTPUT_1: ${{ steps.create_release.outputs.release_url }}
|
|
run: 'echo "Draft-only mode: ${WORKFLOW_OUTPUT_1}"'
|
|
|
|
- name: Summary
|
|
env:
|
|
WORKFLOW_OUTPUT_1: ${{ needs.prepare.outputs.tag }}
|
|
WORKFLOW_OUTPUT_2: ${{ steps.create_release.outputs.release_url }}
|
|
run: |
|
|
echo "[SUCCESS] Release assets staged behind an unpublished draft."
|
|
echo "Release: ${WORKFLOW_OUTPUT_1}"
|
|
echo "URL: ${WORKFLOW_OUTPUT_2}"
|
|
|
|
backfill_release_assets:
|
|
needs:
|
|
- prepare
|
|
- publication_trust_preflight
|
|
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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
with:
|
|
persist-credentials: false
|
|
|
|
- name: Set up Go
|
|
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
|
with:
|
|
go-version-file: go.mod
|
|
cache: false
|
|
|
|
- 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 }}
|
|
WORKFLOW_OUTPUT_1: ${{ needs.prepare.outputs.tag }}
|
|
run: |
|
|
./scripts/backfill-release-assets.sh --tag "${WORKFLOW_OUTPUT_1}" --repo "${{ github.repository }}"
|
|
|
|
- name: Validate published release packet
|
|
env:
|
|
PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }}
|
|
WORKFLOW_OUTPUT_1: ${{ needs.prepare.outputs.tag }}
|
|
run: |
|
|
./scripts/validate-published-release.sh "${WORKFLOW_OUTPUT_1}" "${{ github.repository }}"
|
|
|
|
- name: Summary
|
|
env:
|
|
WORKFLOW_OUTPUT_1: ${{ needs.prepare.outputs.tag }}
|
|
run: |
|
|
echo "[SUCCESS] Historical release assets repaired"
|
|
echo "Release: ${WORKFLOW_OUTPUT_1}"
|
|
|
|
publish_docker:
|
|
needs:
|
|
- prepare
|
|
- build_release_candidate
|
|
- create_release
|
|
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' && github.event.inputs.draft_only != 'true' }}
|
|
permissions:
|
|
contents: read
|
|
packages: write
|
|
id-token: write
|
|
attestations: write
|
|
uses: ./.github/workflows/publish-docker.yml
|
|
secrets: inherit
|
|
with:
|
|
tag: ${{ needs.prepare.outputs.tag }}
|
|
container_artifact: ${{ needs.build_release_candidate.outputs.container_artifact_name }}
|
|
source_sha: ${{ github.sha }}
|
|
|
|
validate_release_assets:
|
|
needs:
|
|
- prepare
|
|
- build_release_candidate
|
|
- create_release
|
|
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
|
|
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: 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 staged draft 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 exact GitHub Release asset bytes before the customer notification.
|
|
#
|
|
# Gated on validate_release_assets success — the smoke depends on the
|
|
# staged 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. Draft-only runs stop after
|
|
# validation and do not enter the customer activation sequence.
|
|
install_sh_smoke:
|
|
needs:
|
|
- prepare
|
|
- create_release
|
|
- validate_release_assets
|
|
if: ${{ always() && needs.prepare.result == 'success' && needs.create_release.result == 'success' && needs.validate_release_assets.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' && github.event.inputs.draft_only != 'true' }}
|
|
permissions:
|
|
# GitHub's release API requires write-level repository access to read
|
|
# assets from an unpublished draft release. The called workflow only
|
|
# performs GET requests, but a read-scoped GITHUB_TOKEN receives 403.
|
|
contents: write
|
|
uses: ./.github/workflows/install-sh-smoke.yml
|
|
secrets: inherit
|
|
with:
|
|
tag: ${{ needs.prepare.outputs.tag }}
|
|
version: ${{ needs.prepare.outputs.version }}
|
|
repository: ${{ github.repository }}
|
|
asset_source: staged
|
|
release_id: ${{ needs.create_release.outputs.release_id }}
|
|
|
|
# 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
|
|
id-token: write
|
|
attestations: write
|
|
uses: ./.github/workflows/publish-helm-chart.yml
|
|
secrets: inherit
|
|
with:
|
|
chart_version: ${{ needs.prepare.outputs.version }}
|
|
app_version: ${{ needs.prepare.outputs.version }}
|
|
|
|
# One immutable-readiness gate joins every exact-version path before the
|
|
# GitHub release crosses its public activation boundary. v6 additionally
|
|
# requires the staged Pro image and signed packet; older release lines have
|
|
# no private Pro job. Mutable indexes, aliases, brokers, and live environments
|
|
# are deliberately excluded from this pre-activation join.
|
|
release_readiness:
|
|
needs:
|
|
- prepare
|
|
- publication_trust_preflight
|
|
- build_release_candidate
|
|
- qualify_release_containers
|
|
- frontend_bundle
|
|
- frontend_checks
|
|
- windows_install_command_smoke
|
|
- backend_tests
|
|
- integration_tests
|
|
- release_smoke
|
|
- create_release
|
|
- publish_docker
|
|
- validate_release_assets
|
|
- install_sh_smoke
|
|
- publish_helm_chart
|
|
- stage_private_pro_runtime
|
|
if: ${{ always() && needs.prepare.result == 'success' && needs.publication_trust_preflight.result == 'success' && needs.build_release_candidate.result == 'success' && needs.qualify_release_containers.result == 'success' && needs.frontend_bundle.result == 'success' && needs.frontend_checks.result == 'success' && needs.windows_install_command_smoke.result == 'success' && needs.backend_tests.result == 'success' && needs.release_smoke.result == 'success' && (needs.integration_tests.result == 'success' || needs.integration_tests.result == 'skipped') && needs.create_release.result == 'success' && needs.publish_docker.result == 'success' && needs.validate_release_assets.result == 'success' && needs.install_sh_smoke.result == 'success' && needs.publish_helm_chart.result == 'success' && ( !startsWith(needs.prepare.outputs.version, '6.') || needs.stage_private_pro_runtime.result == 'success' ) && needs.prepare.outputs.historical_asset_backfill_only != 'true' && github.event.inputs.draft_only != 'true' }}
|
|
runs-on: ubuntu-24.04
|
|
timeout-minutes: 5
|
|
steps:
|
|
- name: Confirm immutable release readiness
|
|
run: echo "All exact-version release paths are ready for customer activation."
|
|
|
|
# Stage the exact private Pro image and signed R2 packet from the anticipated
|
|
# tag and immutable public SHA as soon as preparation succeeds. These assets
|
|
# remain inert until public readiness and activation allow the separate
|
|
# convergence workflow to update the live paid-runtime broker manifest.
|
|
stage_private_pro_runtime:
|
|
needs:
|
|
- prepare
|
|
- publication_trust_preflight
|
|
if: ${{ always() && needs.prepare.result == 'success' && needs.publication_trust_preflight.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: 120
|
|
outputs:
|
|
r2_prefix: ${{ steps.publish.outputs.r2_prefix }}
|
|
steps:
|
|
- name: Checkout private-runtime staging control
|
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
with:
|
|
persist-credentials: false
|
|
|
|
- name: Dispatch and verify private Pro runtime staging
|
|
id: publish
|
|
env:
|
|
GH_TOKEN: ${{ secrets.WORKFLOW_PAT }}
|
|
VERSION: ${{ needs.prepare.outputs.version }}
|
|
TAG: ${{ needs.prepare.outputs.tag }}
|
|
IS_PRERELEASE: ${{ needs.prepare.outputs.is_prerelease }}
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
if [[ -z "${GH_TOKEN:-}" ]]; then
|
|
echo "::error::WORKFLOW_PAT is required to dispatch private Pro publication workflows."
|
|
exit 1
|
|
fi
|
|
|
|
wait_for_workflow() {
|
|
local repo="$1"
|
|
local run_id="$2"
|
|
local label="$3"
|
|
local timeout_seconds="$4"
|
|
local deadline=$((SECONDS + timeout_seconds))
|
|
|
|
if [[ ! "${run_id}" =~ ^[0-9]+$ ]]; then
|
|
echo "::error::Dispatch for ${label} did not return an exact workflow run ID."
|
|
return 1
|
|
fi
|
|
|
|
echo "Watching exact ${label} run ${run_id} in ${repo}."
|
|
|
|
while (( SECONDS < deadline )); do
|
|
run_state="$(
|
|
gh run view "${run_id}" \
|
|
--repo "${repo}" \
|
|
--json status,conclusion,url \
|
|
--jq '[.status, (.conclusion // ""), .url] | @tsv'
|
|
)"
|
|
status="$(awk -F '\t' '{print $1}' <<<"${run_state}")"
|
|
conclusion="$(awk -F '\t' '{print $2}' <<<"${run_state}")"
|
|
url="$(awk -F '\t' '{print $3}' <<<"${run_state}")"
|
|
echo "${label}: status=${status} conclusion=${conclusion:-pending} ${url}"
|
|
if [[ "${status}" == "completed" ]]; then
|
|
if [[ "${conclusion}" == "success" ]]; then
|
|
echo "[OK] ${label} completed successfully: ${url}"
|
|
return 0
|
|
fi
|
|
echo "::error::${label} failed with conclusion=${conclusion}: ${url}"
|
|
return 1
|
|
fi
|
|
|
|
sleep 5
|
|
done
|
|
|
|
echo "::error::Timed out waiting for ${label} after ${timeout_seconds}s."
|
|
return 1
|
|
}
|
|
|
|
allow_ga_publish=false
|
|
if [[ "${IS_PRERELEASE}" != "true" ]]; then
|
|
allow_ga_publish=true
|
|
fi
|
|
|
|
# The R2 prefix must be identical across rerun attempts of this run:
|
|
# a rerun after a promotion-only failure has to reuse the packet the
|
|
# earlier attempt already uploaded instead of tripping the enterprise
|
|
# R2 overwrite guard. Run creation date and run id are stable across
|
|
# attempts; wall-clock date is not.
|
|
run_created_date="$(
|
|
gh run view "${GITHUB_RUN_ID}" \
|
|
--repo "${GITHUB_REPOSITORY}" \
|
|
--json createdAt \
|
|
--jq '.createdAt' | cut -c1-10 | tr -d '-'
|
|
)"
|
|
if [[ ! "${run_created_date}" =~ ^[0-9]{8}$ ]]; then
|
|
echo "::error::Could not derive the release run creation date for the R2 prefix."
|
|
exit 1
|
|
fi
|
|
r2_prefix="${TAG}-pro-${run_created_date}-${GITHUB_RUN_ID}"
|
|
python3 scripts/write_github_output.py r2_prefix "${r2_prefix}"
|
|
echo "Dispatching private Pro build for ${TAG} with R2 prefix ${r2_prefix}."
|
|
build_dispatch="$(
|
|
jq -n \
|
|
--arg pulse_ref "${TAG}" \
|
|
--arg pulse_checkout_ref "${GITHUB_SHA}" \
|
|
--arg version "${VERSION}" \
|
|
--arg r2_prefix "${r2_prefix}" \
|
|
--arg allow_stable_ga_publish "${allow_ga_publish}" \
|
|
'{
|
|
ref: "main",
|
|
return_run_details: true,
|
|
inputs: {
|
|
pulse_ref: $pulse_ref,
|
|
pulse_checkout_ref: $pulse_checkout_ref,
|
|
version: $version,
|
|
upload_actions_artifact: "false",
|
|
upload_to_r2: "true",
|
|
publish_docker_image: "true",
|
|
docker_image: "license.pulserelay.pro/pulse-pro",
|
|
r2_prefix: $r2_prefix,
|
|
reuse_existing_packet: "true",
|
|
allow_stable_ga_publish: $allow_stable_ga_publish,
|
|
allow_pre_activation_staging: "true"
|
|
}
|
|
}' | \
|
|
gh api \
|
|
--method POST \
|
|
-H "Accept: application/vnd.github+json" \
|
|
-H "X-GitHub-Api-Version: 2026-03-10" \
|
|
repos/rcourtman/pulse-enterprise/actions/workflows/build-pro-release.yml/dispatches \
|
|
--input -
|
|
)"
|
|
build_run_id="$(jq -r '.workflow_run_id // empty' <<<"${build_dispatch}")"
|
|
wait_for_workflow rcourtman/pulse-enterprise "${build_run_id}" "private Pro build" 7200
|
|
|
|
# Durably enqueue customer convergence before crossing the irreversible
|
|
# publication boundary. The separate run waits for release-activation.json,
|
|
# so it cannot mutate a customer surface until public verification commits.
|
|
dispatch_release_convergence:
|
|
needs:
|
|
- prepare
|
|
- create_release
|
|
- stage_private_pro_runtime
|
|
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.') || needs.stage_private_pro_runtime.result == 'success' ) }}
|
|
runs-on: ubuntu-24.04
|
|
timeout-minutes: 10
|
|
permissions:
|
|
actions: write
|
|
contents: read
|
|
outputs:
|
|
run_id: ${{ steps.dispatch.outputs.run_id }}
|
|
run_url: ${{ steps.dispatch.outputs.run_url }}
|
|
steps:
|
|
- name: Dispatch durable customer convergence
|
|
id: dispatch
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
TAG: ${{ needs.prepare.outputs.tag }}
|
|
VERSION: ${{ needs.prepare.outputs.version }}
|
|
IS_PRERELEASE: ${{ needs.prepare.outputs.is_prerelease }}
|
|
TARGET_COMMITISH: ${{ needs.create_release.outputs.target_commitish }}
|
|
RELEASE_ID: ${{ needs.create_release.outputs.release_id }}
|
|
R2_PREFIX: ${{ needs.stage_private_pro_runtime.outputs.r2_prefix }}
|
|
run: |
|
|
set -euo pipefail
|
|
dispatch="$(
|
|
jq -n \
|
|
--arg tag "${TAG}" \
|
|
--arg version "${VERSION}" \
|
|
--arg prerelease "${IS_PRERELEASE}" \
|
|
--arg target_commitish "${TARGET_COMMITISH}" \
|
|
--arg release_id "${RELEASE_ID}" \
|
|
--arg r2_prefix "${R2_PREFIX}" \
|
|
--arg source_release_run_id "${GITHUB_RUN_ID}" \
|
|
'{
|
|
ref: "main",
|
|
return_run_details: true,
|
|
inputs: {
|
|
tag: $tag,
|
|
version: $version,
|
|
prerelease: $prerelease,
|
|
target_commitish: $target_commitish,
|
|
release_id: $release_id,
|
|
r2_prefix: $r2_prefix,
|
|
source_release_run_id: $source_release_run_id
|
|
}
|
|
}' | \
|
|
gh api \
|
|
--method POST \
|
|
-H "Accept: application/vnd.github+json" \
|
|
-H "X-GitHub-Api-Version: 2026-03-10" \
|
|
"repos/${{ github.repository }}/actions/workflows/release-convergence.yml/dispatches" \
|
|
--input -
|
|
)"
|
|
run_id="$(jq -r '.workflow_run_id // empty' <<<"${dispatch}")"
|
|
run_url="$(jq -r '.html_url // empty' <<<"${dispatch}")"
|
|
if [[ ! "${run_id}" =~ ^[0-9]+$ ]] || [ -z "${run_url}" ]; then
|
|
echo "::error::Customer convergence dispatch did not return an exact workflow run."
|
|
exit 1
|
|
fi
|
|
echo "run_id=${run_id}" >> "$GITHUB_OUTPUT"
|
|
echo "run_url=${run_url}" >> "$GITHUB_OUTPUT"
|
|
echo "[OK] Customer convergence is durably queued as ${run_url}."
|
|
|
|
# release-activation.json is staged and digest-checked while the release is a
|
|
# draft. Publishing that complete packet is the irreversible commit: GitHub
|
|
# must lock its tag/assets and issue a verifiable release attestation before
|
|
# customer convergence may use the marker.
|
|
activate_release:
|
|
needs:
|
|
- prepare
|
|
- create_release
|
|
- publish_docker
|
|
- publish_helm_chart
|
|
- release_readiness
|
|
- dispatch_release_convergence
|
|
- stage_private_pro_runtime
|
|
if: ${{ always() && needs.prepare.result == 'success' && needs.create_release.result == 'success' && needs.release_readiness.result == 'success' && needs.dispatch_release_convergence.result == 'success' }}
|
|
continue-on-error: true
|
|
runs-on: ubuntu-24.04
|
|
timeout-minutes: 15
|
|
permissions:
|
|
actions: write
|
|
contents: write
|
|
outputs:
|
|
secure_runtime_qualification_run_id: ${{ steps.secure_runtime_qualification.outputs.run_id }}
|
|
secure_runtime_qualification_run_url: ${{ steps.secure_runtime_qualification.outputs.run_url }}
|
|
steps:
|
|
- name: Checkout release integrity control
|
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
with:
|
|
persist-credentials: false
|
|
|
|
- name: Publish the fully staged release
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
IMMUTABILITY_ADMIN_TOKEN: ${{ secrets.WORKFLOW_PAT }}
|
|
TAG: ${{ needs.prepare.outputs.tag }}
|
|
RELEASE_ID: ${{ needs.create_release.outputs.release_id }}
|
|
EXPECTED_COMMIT: ${{ needs.create_release.outputs.target_commitish }}
|
|
IS_PRERELEASE: ${{ needs.prepare.outputs.is_prerelease }}
|
|
CONVERGENCE_RUN_ID: ${{ needs.dispatch_release_convergence.outputs.run_id }}
|
|
R2_PREFIX: ${{ needs.stage_private_pro_runtime.outputs.r2_prefix }}
|
|
SERVER_IMAGE_DIGEST: ${{ needs.publish_docker.outputs.server_digest }}
|
|
CONTROL_PLANE_IMAGE_DIGEST: ${{ needs.publish_docker.outputs.control_plane_digest }}
|
|
HELM_CHART_DIGEST: ${{ needs.publish_helm_chart.outputs.chart_digest }}
|
|
run: |
|
|
set -euo pipefail
|
|
release_json=$(mktemp)
|
|
publish_payload=$(mktemp)
|
|
quarantine_payload=$(mktemp)
|
|
activation_marker_dir=$(mktemp -d)
|
|
activation_marker="${activation_marker_dir}/release-activation.json"
|
|
verified_marker=$(mktemp)
|
|
activated=false
|
|
committed=false
|
|
marker_staged=false
|
|
|
|
validate_existing_activation_commit() {
|
|
local marker_convergence_run_id recovery_run_id recovery_run expected_title
|
|
local convergence_run expected_convergence_title
|
|
curl -fsSL --retry 12 --retry-delay 5 --retry-all-errors \
|
|
-o "${verified_marker}" \
|
|
"https://github.com/${{ github.repository }}/releases/download/${TAG}/release-activation.json"
|
|
jq -e \
|
|
--arg tag "${TAG}" \
|
|
--arg target_commitish "${EXPECTED_COMMIT}" \
|
|
--arg release_id "${RELEASE_ID}" \
|
|
--arg source_release_run_id "${GITHUB_RUN_ID}" \
|
|
--arg r2_prefix "${R2_PREFIX}" \
|
|
--arg server_image_digest "${SERVER_IMAGE_DIGEST}" \
|
|
--arg control_plane_image_digest "${CONTROL_PLANE_IMAGE_DIGEST}" \
|
|
--arg helm_chart_digest "${HELM_CHART_DIGEST}" \
|
|
'.schema_version == 1 and .tag == $tag and
|
|
.target_commitish == $target_commitish and .release_id == $release_id and
|
|
.source_release_run_id == $source_release_run_id and
|
|
(.convergence_run_id | test("^[0-9]+$")) and .r2_prefix == $r2_prefix and
|
|
.server_image_digest == $server_image_digest and
|
|
.control_plane_image_digest == $control_plane_image_digest and
|
|
.helm_chart_digest == $helm_chart_digest' \
|
|
"${verified_marker}" >/dev/null
|
|
|
|
marker_convergence_run_id="$(jq -r '.convergence_run_id' "${verified_marker}")"
|
|
recovery_run_id="$(jq -r '.activation_recovery_run_id // ""' "${verified_marker}")"
|
|
if [ "${marker_convergence_run_id}" = "${CONVERGENCE_RUN_ID}" ] && \
|
|
[ -z "${recovery_run_id}" ]; then
|
|
echo "[OK] ${TAG} already has this release run's exact activation commit."
|
|
return 0
|
|
fi
|
|
if [[ ! "${recovery_run_id}" =~ ^[0-9]+$ ]]; then
|
|
echo "::error::Existing activation marker for ${TAG} has no valid recovery lineage."
|
|
return 1
|
|
fi
|
|
|
|
recovery_run="$(mktemp)"
|
|
gh api "repos/${{ github.repository }}/actions/runs/${recovery_run_id}" > "${recovery_run}"
|
|
expected_title="Recover release activation ${TAG} source ${GITHUB_RUN_ID}"
|
|
jq -e \
|
|
--arg repository "${GITHUB_REPOSITORY}" \
|
|
--arg title "${expected_title}" \
|
|
'.event == "workflow_dispatch" and
|
|
.path == ".github/workflows/recover-release-activation.yml" and
|
|
.head_branch == "main" and .head_repository.full_name == $repository and
|
|
.display_title == $title and .status == "completed" and .conclusion == "success"' \
|
|
"${recovery_run}" >/dev/null
|
|
|
|
convergence_run="$(mktemp)"
|
|
gh api "repos/${{ github.repository }}/actions/runs/${marker_convergence_run_id}" > "${convergence_run}"
|
|
expected_convergence_title="Release convergence ${TAG} source ${GITHUB_RUN_ID}"
|
|
jq -e \
|
|
--arg repository "${GITHUB_REPOSITORY}" \
|
|
--arg title "${expected_convergence_title}" \
|
|
'.event == "workflow_dispatch" and
|
|
.path == ".github/workflows/release-convergence.yml" and
|
|
.head_branch == "main" and .head_repository.full_name == $repository and
|
|
.display_title == $title' \
|
|
"${convergence_run}" >/dev/null
|
|
rm -f "${recovery_run}" "${convergence_run}"
|
|
echo "[OK] ${TAG} was already committed by successful recovery run ${recovery_run_id}; convergence run ${marker_convergence_run_id} owns customer rollout."
|
|
}
|
|
|
|
require_viable_convergence_owner() {
|
|
local attempt owner_state owner_event owner_status owner_conclusion
|
|
local owner_workflow owner_title owner_url expected_title
|
|
expected_title="Release convergence ${TAG} source ${GITHUB_RUN_ID}"
|
|
for attempt in $(seq 1 12); do
|
|
owner_state="$(
|
|
gh run view "${CONVERGENCE_RUN_ID}" \
|
|
--repo "${{ github.repository }}" \
|
|
--json event,status,conclusion,workflowName,displayTitle,url \
|
|
--jq '[.event, .status, (.conclusion // ""), .workflowName, .displayTitle, .url] | @tsv'
|
|
)"
|
|
owner_event="$(awk -F '\t' '{print $1}' <<<"${owner_state}")"
|
|
owner_status="$(awk -F '\t' '{print $2}' <<<"${owner_state}")"
|
|
owner_conclusion="$(awk -F '\t' '{print $3}' <<<"${owner_state}")"
|
|
owner_workflow="$(awk -F '\t' '{print $4}' <<<"${owner_state}")"
|
|
owner_title="$(awk -F '\t' '{print $5}' <<<"${owner_state}")"
|
|
owner_url="$(awk -F '\t' '{print $6}' <<<"${owner_state}")"
|
|
if [ "${owner_event}" = "workflow_dispatch" ] && \
|
|
[ "${owner_workflow}" = "Release Convergence" ] && \
|
|
[ "${owner_title}" = "${expected_title}" ] && \
|
|
[ "${owner_status}" != "completed" ] && \
|
|
[ -z "${owner_conclusion}" ]; then
|
|
echo "Verified viable convergence owner ${CONVERGENCE_RUN_ID}: ${owner_status} ${owner_url}."
|
|
return 0
|
|
fi
|
|
if [ "${owner_status}" = "completed" ] || [ -n "${owner_conclusion}" ]; then
|
|
echo "::error::Exact convergence owner ${CONVERGENCE_RUN_ID} is terminal for ${TAG}: status=${owner_status} conclusion=${owner_conclusion:-none} ${owner_url}."
|
|
return 1
|
|
fi
|
|
echo "Convergence owner ${CONVERGENCE_RUN_ID} metadata is not coherent yet (${attempt}/12); waiting for GitHub indexing."
|
|
sleep 2
|
|
done
|
|
echo "::error::Exact convergence owner ${CONVERGENCE_RUN_ID} metadata did not converge for ${TAG}: event=${owner_event:-missing} workflow=${owner_workflow:-missing} title=${owner_title:-missing} status=${owner_status:-missing} ${owner_url:-}."
|
|
return 1
|
|
}
|
|
|
|
compensate_uncommitted_activation() {
|
|
if [ "$activated" = "true" ] && [ "$committed" != "true" ]; then
|
|
echo "::warning::Release publication did not become immutable; returning ${TAG} to draft quarantine."
|
|
gh api "repos/${{ github.repository }}/releases/${RELEASE_ID}" \
|
|
-X PATCH --input "$quarantine_payload" >/dev/null || true
|
|
fi
|
|
if [ "$marker_staged" = "true" ] && [ "$committed" != "true" ]; then
|
|
marker_asset_id="$(
|
|
gh api --paginate \
|
|
"repos/${{ github.repository }}/releases/${RELEASE_ID}/assets?per_page=100" \
|
|
--jq '.[] | select(.name == "release-activation.json") | .id' \
|
|
2>/dev/null || true
|
|
)"
|
|
if [[ "$marker_asset_id" =~ ^[0-9]+$ ]]; then
|
|
gh api -X DELETE \
|
|
"repos/${{ github.repository }}/releases/assets/${marker_asset_id}" \
|
|
>/dev/null || true
|
|
fi
|
|
fi
|
|
}
|
|
trap compensate_uncommitted_activation ERR
|
|
|
|
gh api "repos/${{ github.repository }}/releases/${RELEASE_ID}" > "$release_json"
|
|
actual_tag=$(jq -r '.tag_name // ""' "$release_json")
|
|
actual_commit=$(jq -r '.target_commitish // ""' "$release_json")
|
|
actual_draft=$(jq -r '.draft' "$release_json")
|
|
published_at=$(jq -r '.published_at // ""' "$release_json")
|
|
actual_prerelease=$(jq -r '.prerelease' "$release_json")
|
|
actual_immutable=$(jq -r '.immutable // false' "$release_json")
|
|
activation_committed=$(jq -r 'any(.assets[]?; .name == "release-activation.json")' "$release_json")
|
|
if [ "$actual_tag" = "$TAG" ] && [ "$actual_commit" = "$EXPECTED_COMMIT" ] && \
|
|
[ "$actual_draft" = "false" ] && [ -n "$published_at" ] && \
|
|
[ "$activation_committed" = "true" ] && \
|
|
[ "$actual_immutable" = "true" ] && \
|
|
[ "$actual_prerelease" = "$IS_PRERELEASE" ]; then
|
|
validate_existing_activation_commit
|
|
./scripts/verify-github-release-integrity.sh \
|
|
"$TAG" "${GITHUB_REPOSITORY}" "$RELEASE_ID" "$EXPECTED_COMMIT" \
|
|
"${verified_marker}"
|
|
rm -f "$release_json" "$publish_payload" "$quarantine_payload" \
|
|
"$verified_marker"
|
|
rm -rf "$activation_marker_dir"
|
|
exit 0
|
|
fi
|
|
if [ "$actual_tag" != "$TAG" ] || [ "$actual_commit" != "$EXPECTED_COMMIT" ] || \
|
|
[ "$actual_draft" != "true" ] || \
|
|
[ "$activation_committed" = "true" ] || \
|
|
[ "$actual_prerelease" != "$IS_PRERELEASE" ]; then
|
|
echo "::error::Release ${RELEASE_ID} no longer matches the staged activation candidate."
|
|
exit 1
|
|
fi
|
|
if [ -n "$published_at" ]; then
|
|
echo "Resuming quarantined activation for ${TAG}; GitHub retained historical published_at=${published_at}."
|
|
fi
|
|
|
|
make_latest=false
|
|
if [ "$IS_PRERELEASE" != "true" ]; then
|
|
highest_stable=$(gh api --paginate "repos/${{ github.repository }}/tags" --jq '.[].name' \
|
|
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1)
|
|
if [ "$TAG" = "$highest_stable" ]; then
|
|
make_latest=true
|
|
fi
|
|
fi
|
|
jq -n --arg make_latest "$make_latest" \
|
|
'{draft: false, make_latest: $make_latest}' > "$publish_payload"
|
|
jq -n '{draft: true, make_latest: "false"}' > "$quarantine_payload"
|
|
|
|
# Close the dispatch-to-commit race before staging the exact marker.
|
|
require_viable_convergence_owner
|
|
if [[ ! "${SERVER_IMAGE_DIGEST}" =~ ^sha256:[0-9a-f]{64}$ ]] || \
|
|
[[ ! "${CONTROL_PLANE_IMAGE_DIGEST}" =~ ^sha256:[0-9a-f]{64}$ ]] || \
|
|
[[ ! "${HELM_CHART_DIGEST}" =~ ^sha256:[0-9a-f]{64}$ ]]; then
|
|
echo "::error::Verified public container and Helm chart digests are required before release activation."
|
|
exit 1
|
|
fi
|
|
jq -n \
|
|
--arg tag "${TAG}" \
|
|
--arg target_commitish "${EXPECTED_COMMIT}" \
|
|
--arg release_id "${RELEASE_ID}" \
|
|
--arg source_release_run_id "${GITHUB_RUN_ID}" \
|
|
--arg convergence_run_id "${CONVERGENCE_RUN_ID}" \
|
|
--arg r2_prefix "${R2_PREFIX}" \
|
|
--arg server_image_digest "${SERVER_IMAGE_DIGEST}" \
|
|
--arg control_plane_image_digest "${CONTROL_PLANE_IMAGE_DIGEST}" \
|
|
--arg helm_chart_digest "${HELM_CHART_DIGEST}" \
|
|
'{
|
|
schema_version: 1,
|
|
tag: $tag,
|
|
target_commitish: $target_commitish,
|
|
release_id: $release_id,
|
|
source_release_run_id: $source_release_run_id,
|
|
convergence_run_id: $convergence_run_id,
|
|
r2_prefix: $r2_prefix,
|
|
server_image_digest: $server_image_digest,
|
|
control_plane_image_digest: $control_plane_image_digest,
|
|
helm_chart_digest: $helm_chart_digest
|
|
}' > "${activation_marker}"
|
|
gh release upload "${TAG}" \
|
|
"${activation_marker}" --clobber \
|
|
--repo "${GITHUB_REPOSITORY}"
|
|
marker_staged=true
|
|
|
|
# GitHub exposes a SHA-256 digest for draft assets. Verify the exact
|
|
# marker bytes before publication makes the asset set unchangeable.
|
|
gh api "repos/${{ github.repository }}/releases/${RELEASE_ID}" > "$release_json"
|
|
expected_marker_digest="sha256:$(sha256sum "${activation_marker}" | awk '{print $1}')"
|
|
actual_marker_digest="$(
|
|
jq -er \
|
|
'[.assets[] | select(.name == "release-activation.json" and .state == "uploaded")] |
|
|
if length == 1 then .[0].digest else error("expected exactly one activation marker") end |
|
|
select(test("^sha256:[0-9a-f]{64}$"))' \
|
|
"$release_json"
|
|
)"
|
|
if [ "$actual_marker_digest" != "$expected_marker_digest" ]; then
|
|
echo "::error::Draft activation marker digest does not match the staged bytes."
|
|
exit 1
|
|
fi
|
|
|
|
# Publication is now the only irreversible boundary. GitHub must
|
|
# confirm the repository setting before publication and report the
|
|
# complete release as immutable afterward. The immediate setting
|
|
# check prevents a mutable public interval if configuration drifts;
|
|
# the response check remains defense in depth.
|
|
require_viable_convergence_owner
|
|
if [ -z "${IMMUTABILITY_ADMIN_TOKEN:-}" ]; then
|
|
echo "::error::WORKFLOW_PAT with repository Administration (read) is required to prove release immutability."
|
|
exit 1
|
|
fi
|
|
GH_TOKEN="${IMMUTABILITY_ADMIN_TOKEN}" \
|
|
./scripts/check-github-release-immutability.sh "${GITHUB_REPOSITORY}"
|
|
unset IMMUTABILITY_ADMIN_TOKEN
|
|
gh api "repos/${{ github.repository }}/releases/${RELEASE_ID}" \
|
|
-X PATCH --input "$publish_payload" > "$release_json"
|
|
activated=true
|
|
if [ "$(jq -r '.draft' "$release_json")" != "false" ] || \
|
|
[ -z "$(jq -r '.published_at // ""' "$release_json")" ] || \
|
|
[ "$(jq -r '.immutable // false' "$release_json")" != "true" ]; then
|
|
echo "::error::GitHub did not publish ${TAG} as an immutable release. Enable repository release immutability before activation."
|
|
exit 1
|
|
fi
|
|
committed=true
|
|
|
|
base="https://github.com/${{ github.repository }}/releases/download/${TAG}"
|
|
for asset_name in \
|
|
checksums.txt \
|
|
install.sh \
|
|
"pulse-provider-msp-${TAG}.tar.gz" \
|
|
"pulse-${TAG}-linux-amd64.tar.gz"; do
|
|
curl -fsSL --retry 12 --retry-delay 5 --retry-all-errors \
|
|
-o /dev/null "${base}/${asset_name}"
|
|
done
|
|
visual_plan=$(mktemp)
|
|
if jq -er '.inputs.release_screenshot_plan | select(type == "string" and length > 0)' \
|
|
"$GITHUB_EVENT_PATH" > "$visual_plan"; then
|
|
while IFS= read -r asset_name; do
|
|
curl -fsSL --retry 12 --retry-delay 5 --retry-all-errors \
|
|
-o /dev/null "${base}/${asset_name}"
|
|
done < <(jq -r '
|
|
.captures[] |
|
|
(if .before == null then empty else "release-note-\(.id)-before.png" end),
|
|
"release-note-\(.id)-now.png"
|
|
' "$visual_plan")
|
|
fi
|
|
rm -f "$visual_plan"
|
|
|
|
curl -fsSL --retry 12 --retry-delay 5 --retry-all-errors \
|
|
-o "${verified_marker}" "${base}/release-activation.json"
|
|
./scripts/verify-github-release-integrity.sh \
|
|
"$TAG" "${GITHUB_REPOSITORY}" "$RELEASE_ID" "$EXPECTED_COMMIT" \
|
|
"${verified_marker}"
|
|
jq -e \
|
|
--arg tag "${TAG}" \
|
|
--arg target_commitish "${EXPECTED_COMMIT}" \
|
|
--arg release_id "${RELEASE_ID}" \
|
|
--arg source_release_run_id "${GITHUB_RUN_ID}" \
|
|
--arg convergence_run_id "${CONVERGENCE_RUN_ID}" \
|
|
--arg r2_prefix "${R2_PREFIX}" \
|
|
--arg server_image_digest "${SERVER_IMAGE_DIGEST}" \
|
|
--arg control_plane_image_digest "${CONTROL_PLANE_IMAGE_DIGEST}" \
|
|
--arg helm_chart_digest "${HELM_CHART_DIGEST}" \
|
|
'.schema_version == 1 and .tag == $tag and .target_commitish == $target_commitish and .release_id == $release_id and .source_release_run_id == $source_release_run_id and .convergence_run_id == $convergence_run_id and .r2_prefix == $r2_prefix and .server_image_digest == $server_image_digest and .control_plane_image_digest == $control_plane_image_digest and .helm_chart_digest == $helm_chart_digest' \
|
|
"${verified_marker}" >/dev/null
|
|
trap - ERR
|
|
rm -f "$release_json" "$publish_payload" "$quarantine_payload" \
|
|
"$verified_marker"
|
|
rm -rf "$activation_marker_dir"
|
|
echo "[OK] Immutably committed, attested, and publicly verified ${TAG}; convergence run ${CONVERGENCE_RUN_ID} owns customer rollout."
|
|
|
|
# A release published with GITHUB_TOKEN does not emit a workflow-triggering
|
|
# release event. Dispatch the qualification explicitly after the immutable
|
|
# packet and activation marker have both been verified.
|
|
- name: Dispatch exact RC secure-runtime qualification
|
|
id: secure_runtime_qualification
|
|
if: ${{ needs.prepare.outputs.is_prerelease == 'true' && contains(needs.prepare.outputs.version, '-rc.') }}
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
TAG: ${{ needs.prepare.outputs.tag }}
|
|
run: |
|
|
set -euo pipefail
|
|
[[ "${TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[1-9][0-9]*$ ]]
|
|
dispatch="$(
|
|
jq -n \
|
|
--arg tag "${TAG}" \
|
|
'{ref: $tag, return_run_details: true, inputs: {tag: $tag}}' | \
|
|
gh api \
|
|
--method POST \
|
|
-H "Accept: application/vnd.github+json" \
|
|
-H "X-GitHub-Api-Version: 2026-03-10" \
|
|
"repos/${GITHUB_REPOSITORY}/actions/workflows/qualify-secure-runtime-release.yml/dispatches" \
|
|
--input -
|
|
)"
|
|
run_id="$(jq -r '.workflow_run_id // empty' <<<"${dispatch}")"
|
|
run_url="$(jq -r '.html_url // empty' <<<"${dispatch}")"
|
|
if [[ ! "${run_id}" =~ ^[0-9]+$ ]] || [[ -z "${run_url}" ]]; then
|
|
echo "::error::Secure-runtime qualification dispatch did not return an exact workflow run."
|
|
exit 1
|
|
fi
|
|
echo "run_id=${run_id}" >> "$GITHUB_OUTPUT"
|
|
echo "run_url=${run_url}" >> "$GITHUB_OUTPUT"
|
|
echo "[OK] Secure-runtime qualification is durably queued as ${run_url}."
|
|
|
|
release_commit_verdict:
|
|
name: Release Activation Commit Verdict
|
|
needs:
|
|
- prepare
|
|
- publication_trust_preflight
|
|
- release_smoke
|
|
- windows_install_command_smoke
|
|
- create_release
|
|
- publish_docker
|
|
- validate_release_assets
|
|
- install_sh_smoke
|
|
- publish_helm_chart
|
|
- release_readiness
|
|
- stage_private_pro_runtime
|
|
- dispatch_release_convergence
|
|
- activate_release
|
|
if: ${{ always() && needs.prepare.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' }}
|
|
runs-on: ubuntu-24.04
|
|
timeout-minutes: 10
|
|
steps:
|
|
- name: Checkout release integrity control
|
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
with:
|
|
persist-credentials: false
|
|
|
|
- name: Enforce irreversible release commit outcome
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
DRAFT_ONLY: ${{ github.event.inputs.draft_only }}
|
|
VERSION: ${{ needs.prepare.outputs.version }}
|
|
TAG: ${{ needs.prepare.outputs.tag }}
|
|
EXPECTED_COMMIT: ${{ needs.create_release.outputs.target_commitish }}
|
|
RELEASE_ID: ${{ needs.create_release.outputs.release_id }}
|
|
PUBLICATION_TRUST_RESULT: ${{ needs.publication_trust_preflight.result }}
|
|
CREATE_RESULT: ${{ needs.create_release.result }}
|
|
SMOKE_RESULT: ${{ needs.release_smoke.result }}
|
|
WINDOWS_INSTALL_COMMAND_RESULT: ${{ needs.windows_install_command_smoke.result }}
|
|
DOCKER_RESULT: ${{ needs.publish_docker.result }}
|
|
VALIDATE_RESULT: ${{ needs.validate_release_assets.result }}
|
|
INSTALL_RESULT: ${{ needs.install_sh_smoke.result }}
|
|
HELM_RESULT: ${{ needs.publish_helm_chart.result }}
|
|
READINESS_RESULT: ${{ needs.release_readiness.result }}
|
|
PRIVATE_PRO_STAGE_RESULT: ${{ needs.stage_private_pro_runtime.result }}
|
|
CONVERGENCE_DISPATCH_RESULT: ${{ needs.dispatch_release_convergence.result }}
|
|
CONVERGENCE_RUN_ID: ${{ needs.dispatch_release_convergence.outputs.run_id }}
|
|
CONVERGENCE_RUN_URL: ${{ needs.dispatch_release_convergence.outputs.run_url }}
|
|
SECURE_RUNTIME_QUALIFICATION_RUN_ID: ${{ needs.activate_release.outputs.secure_runtime_qualification_run_id }}
|
|
SECURE_RUNTIME_QUALIFICATION_RUN_URL: ${{ needs.activate_release.outputs.secure_runtime_qualification_run_url }}
|
|
R2_PREFIX: ${{ needs.stage_private_pro_runtime.outputs.r2_prefix }}
|
|
SERVER_IMAGE_DIGEST: ${{ needs.publish_docker.outputs.server_digest }}
|
|
CONTROL_PLANE_IMAGE_DIGEST: ${{ needs.publish_docker.outputs.control_plane_digest }}
|
|
HELM_CHART_DIGEST: ${{ needs.publish_helm_chart.outputs.chart_digest }}
|
|
run: |
|
|
set -euo pipefail
|
|
require_result() {
|
|
local name="$1"
|
|
local actual="$2"
|
|
local expected="$3"
|
|
if [ "$actual" != "$expected" ]; then
|
|
echo "::error::${name} ended as ${actual}; expected ${expected}."
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
require_result "publication trust preflight" "$PUBLICATION_TRUST_RESULT" success
|
|
require_result "release smoke" "$SMOKE_RESULT" success
|
|
require_result "Windows install command smoke" "$WINDOWS_INSTALL_COMMAND_RESULT" success
|
|
require_result "release staging" "$CREATE_RESULT" success
|
|
require_result "release asset validation" "$VALIDATE_RESULT" success
|
|
|
|
if [ "${DRAFT_ONLY:-false}" != "true" ]; then
|
|
require_result "exact-version Docker staging" "$DOCKER_RESULT" success
|
|
require_result "staged install.sh smoke" "$INSTALL_RESULT" success
|
|
require_result "Helm staging" "$HELM_RESULT" success
|
|
require_result "immutable release readiness" "$READINESS_RESULT" success
|
|
require_result "durable customer convergence dispatch" "$CONVERGENCE_DISPATCH_RESULT" success
|
|
if [[ "$VERSION" =~ -rc\.[1-9][0-9]*$ ]]; then
|
|
if [[ ! "$SECURE_RUNTIME_QUALIFICATION_RUN_ID" =~ ^[0-9]+$ ]] || \
|
|
[[ ! "$SECURE_RUNTIME_QUALIFICATION_RUN_URL" =~ ^https://github\.com/${GITHUB_REPOSITORY}/actions/runs/[0-9]+$ ]]; then
|
|
echo "::error::Immutable RC publication did not retain an exact secure-runtime qualification run identity."
|
|
exit 1
|
|
fi
|
|
echo "[OK] Secure-runtime qualification run: ${SECURE_RUNTIME_QUALIFICATION_RUN_URL}"
|
|
fi
|
|
if [[ "$VERSION" == 6.* ]]; then
|
|
require_result "private Pro staging" "$PRIVATE_PRO_STAGE_RESULT" success
|
|
fi
|
|
|
|
./scripts/verify-github-release-integrity.sh \
|
|
"$TAG" "${GITHUB_REPOSITORY}" "$RELEASE_ID" "$EXPECTED_COMMIT"
|
|
|
|
marker="$(mktemp)"
|
|
curl -fsSL --retry 6 --retry-delay 5 --retry-all-errors \
|
|
-o "${marker}" \
|
|
"https://github.com/${{ github.repository }}/releases/download/${TAG}/release-activation.json"
|
|
jq -e \
|
|
--arg tag "${TAG}" \
|
|
--arg target_commitish "${EXPECTED_COMMIT}" \
|
|
--arg release_id "${RELEASE_ID}" \
|
|
--arg source_release_run_id "${GITHUB_RUN_ID}" \
|
|
--arg r2_prefix "${R2_PREFIX}" \
|
|
--arg server_image_digest "${SERVER_IMAGE_DIGEST}" \
|
|
--arg control_plane_image_digest "${CONTROL_PLANE_IMAGE_DIGEST}" \
|
|
--arg helm_chart_digest "${HELM_CHART_DIGEST}" \
|
|
'.schema_version == 1 and .tag == $tag and
|
|
.target_commitish == $target_commitish and .release_id == $release_id and
|
|
.source_release_run_id == $source_release_run_id and
|
|
(.convergence_run_id | test("^[0-9]+$")) and .r2_prefix == $r2_prefix and
|
|
.server_image_digest == $server_image_digest and
|
|
.control_plane_image_digest == $control_plane_image_digest and
|
|
.helm_chart_digest == $helm_chart_digest' \
|
|
"${marker}" >/dev/null
|
|
marker_convergence_run_id="$(jq -r '.convergence_run_id' "${marker}")"
|
|
recovery_run_id="$(jq -r '.activation_recovery_run_id // ""' "${marker}")"
|
|
if [ "${marker_convergence_run_id}" != "${CONVERGENCE_RUN_ID}" ] || \
|
|
[ -n "${recovery_run_id}" ]; then
|
|
if [[ ! "${recovery_run_id}" =~ ^[0-9]+$ ]]; then
|
|
echo "::error::Activation marker for ${TAG} does not belong to the staged convergence owner or a qualified recovery."
|
|
exit 1
|
|
fi
|
|
recovery_run="$(mktemp)"
|
|
gh api "repos/${{ github.repository }}/actions/runs/${recovery_run_id}" > "${recovery_run}"
|
|
jq -e \
|
|
--arg repository "${GITHUB_REPOSITORY}" \
|
|
--arg title "Recover release activation ${TAG} source ${GITHUB_RUN_ID}" \
|
|
'.event == "workflow_dispatch" and
|
|
.path == ".github/workflows/recover-release-activation.yml" and
|
|
.head_branch == "main" and .head_repository.full_name == $repository and
|
|
.display_title == $title and .status == "completed" and .conclusion == "success"' \
|
|
"${recovery_run}" >/dev/null
|
|
convergence_run="$(mktemp)"
|
|
gh api "repos/${{ github.repository }}/actions/runs/${marker_convergence_run_id}" > "${convergence_run}"
|
|
jq -e \
|
|
--arg repository "${GITHUB_REPOSITORY}" \
|
|
--arg title "Release convergence ${TAG} source ${GITHUB_RUN_ID}" \
|
|
'.event == "workflow_dispatch" and
|
|
.path == ".github/workflows/release-convergence.yml" and
|
|
.head_branch == "main" and .head_repository.full_name == $repository and
|
|
.display_title == $title' \
|
|
"${convergence_run}" >/dev/null
|
|
rm -f "${recovery_run}" "${convergence_run}"
|
|
echo "Release activation was committed by qualified recovery run ${recovery_run_id}; customer convergence continues in run ${marker_convergence_run_id}."
|
|
fi
|
|
rm -f "${marker}"
|
|
fi
|
|
|
|
echo "Release activation commit passed for v${VERSION}."
|
|
if [ "${DRAFT_ONLY:-false}" != "true" ]; then
|
|
echo "Customer convergence continues independently in ${CONVERGENCE_RUN_URL}."
|
|
fi
|