mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 14:00:29 +00:00
f503b13442
A workflow dispatch by branch can resolve after that branch moves, allowing an unreviewed tip to enter the release pipeline. Require every publishing dispatch to name its expected source SHA and make the workflow reject a different source or workflow commit before checkout.
Change-source: pulse-maintainer
(cherry picked from commit a461fc9c0a)
2241 lines
103 KiB
YAML
2241 lines
103 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
|
|
expected_source_sha:
|
|
description: 'Exact 40-character commit SHA admitted for this release'
|
|
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: Verify admitted source commit
|
|
env:
|
|
EXPECTED_SOURCE_SHA: ${{ inputs.expected_source_sha }}
|
|
run: |
|
|
set -euo pipefail
|
|
if [[ ! "${EXPECTED_SOURCE_SHA}" =~ ^[0-9a-f]{40}$ ]]; then
|
|
echo "::error::expected_source_sha must be an exact 40-character commit SHA"
|
|
exit 1
|
|
fi
|
|
if [[ "${GITHUB_SHA}" != "${EXPECTED_SOURCE_SHA}" || \
|
|
"${GITHUB_WORKFLOW_SHA}" != "${EXPECTED_SOURCE_SHA}" ]]; then
|
|
echo "::error::Release dispatch expected ${EXPECTED_SOURCE_SHA}, but GitHub resolved source ${GITHUB_SHA} and workflow ${GITHUB_WORKFLOW_SHA}."
|
|
exit 1
|
|
fi
|
|
echo "[OK] Release dispatch is bound to ${EXPECTED_SOURCE_SHA}"
|
|
|
|
- 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
|