mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-22 03:04:03 +00:00
fix(release): stage artifacts before publication
This commit is contained in:
@@ -1170,44 +1170,13 @@ jobs:
|
||||
release_upload_with_retry "${TAG}" release/install-docker.sh --clobber
|
||||
release_upload_with_retry "${TAG}" release/pulse-auto-update.sh --clobber
|
||||
|
||||
- name: Publish release
|
||||
if: ${{ github.event.inputs.draft_only != 'true' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
TAG="${{ needs.prepare.outputs.tag }}"
|
||||
RELEASE_ID="${{ steps.create_release.outputs.release_id }}"
|
||||
IS_PRERELEASE="${{ needs.prepare.outputs.is_prerelease }}"
|
||||
|
||||
if [ "$IS_PRERELEASE" = "true" ]; then
|
||||
gh api "repos/${{ github.repository }}/releases/${RELEASE_ID}" \
|
||||
-X PATCH -F draft=false -F make_latest=false
|
||||
echo "[OK] Published as prerelease: ${TAG}"
|
||||
else
|
||||
# 'latest' belongs to the highest stable semver overall. A
|
||||
# maintenance cut of an older line (e.g. v5.1.36 after v6 GA)
|
||||
# publishes without stealing the latest marker from the current
|
||||
# line.
|
||||
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
|
||||
gh api "repos/${{ github.repository }}/releases/${RELEASE_ID}" \
|
||||
-X PATCH -F draft=false -F make_latest=true
|
||||
echo "[OK] Published as latest: ${TAG}"
|
||||
else
|
||||
gh api "repos/${{ github.repository }}/releases/${RELEASE_ID}" \
|
||||
-X PATCH -F draft=false -F make_latest=false
|
||||
echo "[OK] Published WITHOUT latest marker: ${TAG} (highest stable is ${HIGHEST_STABLE})"
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Skip publish (draft only)
|
||||
- name: Stop after staging (draft only)
|
||||
if: ${{ github.event.inputs.draft_only == 'true' }}
|
||||
run: 'echo "Draft-only mode: ${{ steps.create_release.outputs.release_url }}"'
|
||||
|
||||
- name: Summary
|
||||
run: |
|
||||
echo "[SUCCESS] Release published!"
|
||||
echo "[SUCCESS] Release assets staged behind an unpublished draft."
|
||||
echo "Release: ${{ needs.prepare.outputs.tag }}"
|
||||
echo "URL: ${{ steps.create_release.outputs.release_url }}"
|
||||
|
||||
@@ -1295,30 +1264,30 @@ jobs:
|
||||
tag: ${{ needs.prepare.outputs.tag }}
|
||||
version: ${{ needs.prepare.outputs.version }}
|
||||
release_id: ${{ needs.create_release.outputs.release_id }}
|
||||
draft: ${{ github.event.inputs.draft_only == 'true' }}
|
||||
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 just-published release. Catches
|
||||
# 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 published GitHub Release URL.
|
||||
# the exact GitHub Release asset bytes before the customer notification.
|
||||
#
|
||||
# Gated on validate_release_assets success — the smoke depends on the
|
||||
# published asset bundle being well-formed, so we only run it after 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. Also skipped for
|
||||
# draft-only runs because draft release assets are not available at the
|
||||
# public /releases/download/<tag>/ URL this smoke intentionally exercises.
|
||||
# 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.validate_release_assets.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' && github.event.inputs.draft_only != 'true' }}
|
||||
if: ${{ always() && needs.prepare.result == 'success' && needs.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:
|
||||
contents: read
|
||||
uses: ./.github/workflows/install-sh-smoke.yml
|
||||
@@ -1327,12 +1296,15 @@ jobs:
|
||||
tag: ${{ needs.prepare.outputs.tag }}
|
||||
version: ${{ needs.prepare.outputs.version }}
|
||||
repository: ${{ github.repository }}
|
||||
asset_source: staged
|
||||
release_id: ${{ needs.create_release.outputs.release_id }}
|
||||
|
||||
update_stable_demo:
|
||||
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' && needs.prepare.outputs.is_prerelease != 'true' && startsWith(needs.prepare.outputs.version, '6.') }}
|
||||
- create_release
|
||||
- release_readiness
|
||||
if: ${{ always() && needs.prepare.result == 'success' && needs.create_release.result == 'success' && needs.release_readiness.result == 'success' && needs.prepare.outputs.is_prerelease != 'true' && startsWith(needs.prepare.outputs.version, '6.') }}
|
||||
permissions:
|
||||
contents: read
|
||||
uses: ./.github/workflows/update-demo-server.yml
|
||||
@@ -1341,6 +1313,7 @@ jobs:
|
||||
tag: ${{ needs.prepare.outputs.tag }}
|
||||
target: stable
|
||||
verify_only: false
|
||||
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
|
||||
@@ -1366,20 +1339,52 @@ jobs:
|
||||
chart_version: ${{ needs.prepare.outputs.version }}
|
||||
app_version: ${{ needs.prepare.outputs.version }}
|
||||
|
||||
# Defensive backup to promote-floating-tags.yml's workflow_run chain off
|
||||
# publish-docker.yml. The chain works when publish-docker succeeds, but
|
||||
# when it fails the floating tags don't advance and customers pulling
|
||||
# rcourtman/pulse:latest stay on whatever the previous successful release
|
||||
# tagged. Calling promote-floating-tags as workflow_call after
|
||||
# validate_release_assets and publish_docker succeed guarantees the floating
|
||||
# tags advance. Draft-only runs must not promote floating tags because the
|
||||
# release is still in private promotion state.
|
||||
promote_floating_tags:
|
||||
# Stage and prove the customer-facing Helm Pages repository explicitly.
|
||||
# The legacy workflow_run chain was asynchronous and could leave the public
|
||||
# index behind an already-visible GitHub release.
|
||||
publish_helm_pages:
|
||||
needs:
|
||||
- prepare
|
||||
- publish_docker
|
||||
- validate_release_assets
|
||||
if: ${{ always() && needs.prepare.result == 'success' && needs.publish_docker.result == 'success' && needs.validate_release_assets.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' && github.event.inputs.draft_only != 'true' }}
|
||||
- publish_helm_chart
|
||||
if: ${{ always() && needs.prepare.result == 'success' && needs.publish_docker.result == 'success' && needs.validate_release_assets.result == 'success' && needs.publish_helm_chart.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' && github.event.inputs.draft_only != 'true' }}
|
||||
permissions:
|
||||
contents: write
|
||||
uses: ./.github/workflows/helm-pages.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
chart_version: ${{ needs.prepare.outputs.version }}
|
||||
|
||||
# One immutable-readiness gate joins every exact-version path before any
|
||||
# mutable customer pointer moves. v6 additionally requires the staged Pro
|
||||
# image and signed packet; older release lines have no private Pro job.
|
||||
release_readiness:
|
||||
needs:
|
||||
- prepare
|
||||
- create_release
|
||||
- publish_docker
|
||||
- validate_release_assets
|
||||
- install_sh_smoke
|
||||
- publish_helm_chart
|
||||
- publish_helm_pages
|
||||
- stage_private_pro_runtime
|
||||
if: ${{ always() && needs.prepare.result == 'success' && 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' && needs.publish_helm_pages.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."
|
||||
|
||||
# Mutable image aliases move only after the exact-version images and staged
|
||||
# release assets have passed validation. promote-floating-tags.yml has no
|
||||
# implicit workflow_run trigger, so this explicit call is the sole owner of
|
||||
# rc/latest/major/minor pointers for both runtime images.
|
||||
promote_floating_tags:
|
||||
needs:
|
||||
- prepare
|
||||
- release_readiness
|
||||
if: ${{ always() && needs.prepare.result == 'success' && needs.release_readiness.result == 'success' }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
@@ -1389,26 +1394,21 @@ jobs:
|
||||
tag: ${{ needs.prepare.outputs.tag }}
|
||||
prerelease: ${{ needs.prepare.outputs.is_prerelease == 'true' }}
|
||||
|
||||
# Customer-facing v6 public releases must not outrun the private Pulse Pro
|
||||
# runtime path. The public release is the immutable source tag; this job
|
||||
# dispatches the private build against that exact tag, waits for the R2 and
|
||||
# Docker publication workflow to pass, then dispatches the pulse-pro live
|
||||
# promotion workflow and waits for the signed packet to update the license
|
||||
# broker. A failure here fails the release pipeline instead of leaving paid
|
||||
# customers on a stale private manifest. Rerunning this job after a
|
||||
# promotion-only failure is safe: the R2 prefix is derived from run-stable
|
||||
# values and the build is dispatched with reuse_existing_packet=true, so the
|
||||
# enterprise side validates the packet the earlier attempt uploaded, skips
|
||||
# the rebuild, and only the promotion re-executes.
|
||||
publish_private_pro_runtime:
|
||||
# Stage the exact private Pro image and signed R2 packet as soon as the draft
|
||||
# release/tag exist. This slow build runs in parallel with public artifact
|
||||
# validation, but does not update the live paid-runtime broker manifest.
|
||||
stage_private_pro_runtime:
|
||||
needs:
|
||||
- prepare
|
||||
- create_release
|
||||
if: ${{ always() && needs.prepare.result == 'success' && needs.create_release.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' && github.event.inputs.draft_only != 'true' && startsWith(needs.prepare.outputs.version, '6.') }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 150
|
||||
timeout-minutes: 120
|
||||
outputs:
|
||||
r2_prefix: ${{ steps.publish.outputs.r2_prefix }}
|
||||
steps:
|
||||
- name: Dispatch and verify private Pro runtime publication
|
||||
- name: Dispatch and verify private Pro runtime staging
|
||||
id: publish
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.WORKFLOW_PAT }}
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
@@ -1500,6 +1500,7 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
r2_prefix="${TAG}-pro-${run_created_date}-${GITHUB_RUN_ID}"
|
||||
echo "r2_prefix=${r2_prefix}" >> "$GITHUB_OUTPUT"
|
||||
build_started_at="$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
|
||||
echo "Dispatching private Pro build for ${TAG} with R2 prefix ${r2_prefix}."
|
||||
gh workflow run build-pro-release.yml \
|
||||
@@ -1516,16 +1517,196 @@ jobs:
|
||||
-f allow_stable_ga_publish="${allow_ga_publish}"
|
||||
wait_for_workflow rcourtman/pulse-enterprise "Build Pro Release" main "${build_started_at}" "private Pro build" 7200
|
||||
|
||||
# The paid-runtime broker is a mutable customer pointer, so promote it only
|
||||
# after every exact-version public artifact and staged install path is ready.
|
||||
# A rerun reuses the run-stable R2 prefix produced by the staging job.
|
||||
promote_private_pro_runtime:
|
||||
needs:
|
||||
- prepare
|
||||
- release_readiness
|
||||
- stage_private_pro_runtime
|
||||
if: ${{ always() && needs.prepare.result == 'success' && needs.release_readiness.result == 'success' && needs.stage_private_pro_runtime.result == 'success' && startsWith(needs.prepare.outputs.version, '6.') }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 65
|
||||
steps:
|
||||
- name: Promote and verify private Pro runtime
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.WORKFLOW_PAT }}
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
TAG: ${{ needs.prepare.outputs.tag }}
|
||||
IS_PRERELEASE: ${{ needs.prepare.outputs.is_prerelease }}
|
||||
R2_PREFIX: ${{ needs.stage_private_pro_runtime.outputs.r2_prefix }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [[ -z "${GH_TOKEN:-}" ]]; then
|
||||
echo "::error::WORKFLOW_PAT is required to dispatch private Pro promotion workflows."
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "${R2_PREFIX:-}" ]]; then
|
||||
echo "::error::Private Pro staging did not return an R2 prefix."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
wait_for_workflow() {
|
||||
local repo="$1"
|
||||
local workflow="$2"
|
||||
local branch="$3"
|
||||
local started_at="$4"
|
||||
local label="$5"
|
||||
local timeout_seconds="$6"
|
||||
local deadline=$((SECONDS + timeout_seconds))
|
||||
local run_id=""
|
||||
|
||||
while (( SECONDS < deadline )); do
|
||||
if [[ -z "${run_id}" ]]; then
|
||||
run_id="$(
|
||||
gh run list \
|
||||
--repo "${repo}" \
|
||||
--workflow "${workflow}" \
|
||||
--event workflow_dispatch \
|
||||
--branch "${branch}" \
|
||||
--limit 50 \
|
||||
--json databaseId,createdAt \
|
||||
--jq "map(select(.createdAt >= \"${started_at}\")) | sort_by(.createdAt) | reverse | .[0].databaseId // \"\""
|
||||
)"
|
||||
if [[ -n "${run_id}" ]]; then
|
||||
echo "Watching ${label} run ${run_id} in ${repo}."
|
||||
else
|
||||
echo "Waiting for ${label} workflow run to appear..."
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -n "${run_id}" ]]; then
|
||||
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
|
||||
fi
|
||||
|
||||
sleep 30
|
||||
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
|
||||
promote_started_at="$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
|
||||
echo "Dispatching live paid-runtime promotion for ${TAG} with R2 prefix ${r2_prefix}."
|
||||
echo "Dispatching live paid-runtime promotion for ${TAG} with R2 prefix ${R2_PREFIX}."
|
||||
gh workflow run promote-paid-runtime-release.yml \
|
||||
--repo rcourtman/pulse-pro \
|
||||
--ref main \
|
||||
-f version="${VERSION}" \
|
||||
-f r2_prefix="${r2_prefix}" \
|
||||
-f r2_prefix="${R2_PREFIX}" \
|
||||
-f allow_ga_prefix="${allow_ga_publish}"
|
||||
wait_for_workflow rcourtman/pulse-pro "Promote Paid Runtime Release" main "${promote_started_at}" "private Pro live promotion" 3600
|
||||
|
||||
# Customer activation is one final publication barrier. All exact-version
|
||||
# artifacts, mutable image aliases, and the live paid-runtime manifest are
|
||||
# ready before the GitHub release becomes visible. If public asset reads fail
|
||||
# immediately after activation, quarantine the release back to draft.
|
||||
activate_release:
|
||||
needs:
|
||||
- prepare
|
||||
- create_release
|
||||
- release_readiness
|
||||
- update_stable_demo
|
||||
- promote_floating_tags
|
||||
- promote_private_pro_runtime
|
||||
if: ${{ always() && needs.prepare.result == 'success' && needs.create_release.result == 'success' && needs.release_readiness.result == 'success' && needs.promote_floating_tags.result == 'success' && ( !startsWith(needs.prepare.outputs.version, '6.') || needs.promote_private_pro_runtime.result == 'success' ) && ( !startsWith(needs.prepare.outputs.version, '6.') || needs.prepare.outputs.is_prerelease == 'true' || needs.update_stable_demo.result == 'success' ) }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Publish the fully staged release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
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 }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
release_json=$(mktemp)
|
||||
publish_payload=$(mktemp)
|
||||
quarantine_payload=$(mktemp)
|
||||
activated=false
|
||||
|
||||
quarantine_on_error() {
|
||||
if [ "$activated" = "true" ]; then
|
||||
echo "::warning::Public asset verification failed; returning ${TAG} to draft quarantine."
|
||||
gh api "repos/${{ github.repository }}/releases/${RELEASE_ID}" \
|
||||
-X PATCH --input "$quarantine_payload" >/dev/null || true
|
||||
fi
|
||||
}
|
||||
trap quarantine_on_error 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")
|
||||
if [ "$actual_tag" != "$TAG" ] || [ "$actual_commit" != "$EXPECTED_COMMIT" ] || \
|
||||
[ "$actual_draft" != "true" ] || [ -n "$published_at" ] || \
|
||||
[ "$actual_prerelease" != "$IS_PRERELEASE" ]; then
|
||||
echo "::error::Release ${RELEASE_ID} no longer matches the staged activation candidate."
|
||||
exit 1
|
||||
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"
|
||||
|
||||
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")" ]; then
|
||||
echo "::error::GitHub did not publish release ${RELEASE_ID}."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
base="https://github.com/${{ github.repository }}/releases/download/${TAG}"
|
||||
for asset_name in \
|
||||
checksums.txt \
|
||||
install.sh \
|
||||
"pulse-${TAG}-linux-amd64.tar.gz"; do
|
||||
curl -fsSL --retry 12 --retry-delay 5 --retry-all-errors \
|
||||
-o /dev/null "${base}/${asset_name}"
|
||||
done
|
||||
|
||||
trap - ERR
|
||||
rm -f "$release_json" "$publish_payload" "$quarantine_payload"
|
||||
echo "[OK] Activated ${TAG} after every customer-facing release path was ready."
|
||||
|
||||
release_verdict:
|
||||
name: Definitive Release Verdict
|
||||
needs:
|
||||
@@ -1538,8 +1719,12 @@ jobs:
|
||||
- install_sh_smoke
|
||||
- update_stable_demo
|
||||
- publish_helm_chart
|
||||
- publish_helm_pages
|
||||
- release_readiness
|
||||
- promote_floating_tags
|
||||
- publish_private_pro_runtime
|
||||
- stage_private_pro_runtime
|
||||
- promote_private_pro_runtime
|
||||
- activate_release
|
||||
if: ${{ always() && needs.prepare.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' }}
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
@@ -1556,8 +1741,12 @@ jobs:
|
||||
INSTALL_RESULT: ${{ needs.install_sh_smoke.result }}
|
||||
DEMO_RESULT: ${{ needs.update_stable_demo.result }}
|
||||
HELM_RESULT: ${{ needs.publish_helm_chart.result }}
|
||||
HELM_PAGES_RESULT: ${{ needs.publish_helm_pages.result }}
|
||||
READINESS_RESULT: ${{ needs.release_readiness.result }}
|
||||
FLOATING_RESULT: ${{ needs.promote_floating_tags.result }}
|
||||
PRIVATE_PRO_RESULT: ${{ needs.publish_private_pro_runtime.result }}
|
||||
PRIVATE_PRO_STAGE_RESULT: ${{ needs.stage_private_pro_runtime.result }}
|
||||
PRIVATE_PRO_PROMOTION_RESULT: ${{ needs.promote_private_pro_runtime.result }}
|
||||
ACTIVATION_RESULT: ${{ needs.activate_release.result }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
require_result() {
|
||||
@@ -1572,16 +1761,20 @@ jobs:
|
||||
|
||||
require_result "release smoke" "$SMOKE_RESULT" success
|
||||
require_result "Windows install command smoke" "$WINDOWS_INSTALL_COMMAND_RESULT" success
|
||||
require_result "release assembly" "$CREATE_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 "Docker publication" "$DOCKER_RESULT" success
|
||||
require_result "install.sh smoke" "$INSTALL_RESULT" success
|
||||
require_result "Helm publication" "$HELM_RESULT" success
|
||||
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 "public Helm repository staging" "$HELM_PAGES_RESULT" success
|
||||
require_result "immutable release readiness" "$READINESS_RESULT" success
|
||||
require_result "floating-tag promotion" "$FLOATING_RESULT" success
|
||||
require_result "release activation" "$ACTIVATION_RESULT" success
|
||||
if [[ "$VERSION" == 6.* ]]; then
|
||||
require_result "private Pro publication" "$PRIVATE_PRO_RESULT" success
|
||||
require_result "private Pro staging" "$PRIVATE_PRO_STAGE_RESULT" success
|
||||
require_result "private Pro live promotion" "$PRIVATE_PRO_PROMOTION_RESULT" success
|
||||
if [ "$IS_PRERELEASE" != "true" ]; then
|
||||
require_result "stable demo deployment and verification" "$DEMO_RESULT" success
|
||||
fi
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
name: Release Helm Chart to GitHub Pages
|
||||
run-name: Release Helm Chart ${{ inputs.chart_version }}
|
||||
|
||||
# Triggered automatically when publish-docker.yml completes, or manually
|
||||
# We wait for Docker publish because the smoke test pulls the Docker image
|
||||
# Called explicitly from create-release.yml after the exact Docker image is
|
||||
# pullable, or dispatched manually for a governed backfill. The release
|
||||
# pipeline awaits this workflow before customer activation.
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Publish Docker Images"]
|
||||
types: [completed]
|
||||
workflow_call:
|
||||
inputs:
|
||||
chart_version:
|
||||
description: "Chart version (e.g., 6.2.0-rc.9)."
|
||||
required: true
|
||||
type: string
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
chart_version:
|
||||
@@ -19,31 +23,11 @@ permissions:
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-24.04
|
||||
# Only run if workflow_dispatch OR if workflow_run completed successfully
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
|
||||
steps:
|
||||
- name: Determine chart version
|
||||
id: version
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
VERSION="${{ inputs.chart_version }}"
|
||||
else
|
||||
RUN_ID="${{ github.event.workflow_run.id }}"
|
||||
echo "Extracting version from workflow run ${RUN_ID}..."
|
||||
|
||||
WORKFLOW_DATA=$(gh api repos/${{ github.repository }}/actions/runs/${RUN_ID})
|
||||
TAG=$(echo "$WORKFLOW_DATA" | jq -r '.display_title' | grep -oP 'v?\d+\.\d+\.\d+(-[a-zA-Z]+\.\d+)?' || echo "")
|
||||
|
||||
if [ -z "$TAG" ]; then
|
||||
echo "::error::Could not extract version from workflow_run"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VERSION="${TAG#v}"
|
||||
fi
|
||||
|
||||
VERSION="${{ inputs.chart_version }}"
|
||||
if [ -z "$VERSION" ]; then
|
||||
echo "::error::chart_version input is required"
|
||||
exit 1
|
||||
@@ -70,12 +54,12 @@ jobs:
|
||||
--purpose "Helm pages release" \
|
||||
--github-output "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Check out governed release branch
|
||||
- name: Check out validated release tag
|
||||
env:
|
||||
REQUIRED_BRANCH: ${{ steps.release_line.outputs.required_branch }}
|
||||
RELEASE_TAG: ${{ steps.version.outputs.release_tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git checkout -B "$REQUIRED_BRANCH" "origin/$REQUIRED_BRANCH"
|
||||
git checkout --detach "refs/tags/${RELEASE_TAG}"
|
||||
|
||||
- name: Configure Git
|
||||
run: |
|
||||
@@ -100,27 +84,13 @@ jobs:
|
||||
helm-docs --version
|
||||
|
||||
- name: Generate chart documentation
|
||||
env:
|
||||
REQUIRED_BRANCH: ${{ steps.release_line.outputs.required_branch }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd deploy/helm/pulse
|
||||
helm-docs
|
||||
|
||||
# Commit if README changed
|
||||
if ! git diff --quiet README.md; then
|
||||
git config user.name "$GITHUB_ACTOR"
|
||||
git config user.email "$GITHUB_ACTOR@users.noreply.github.com"
|
||||
git add README.md
|
||||
git commit -m "Auto-update Helm chart documentation"
|
||||
git pull --rebase origin "$REQUIRED_BRANCH"
|
||||
git push origin HEAD:"$REQUIRED_BRANCH"
|
||||
fi
|
||||
cd ../../..
|
||||
|
||||
- name: Update Chart.yaml version
|
||||
env:
|
||||
REQUIRED_BRANCH: ${{ steps.release_line.outputs.required_branch }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
@@ -129,16 +99,6 @@ jobs:
|
||||
--version "$VERSION" \
|
||||
--repo "${{ github.repository }}"
|
||||
|
||||
# Commit if Chart.yaml changed
|
||||
if ! git diff --quiet deploy/helm/pulse/Chart.yaml; then
|
||||
git config user.name "$GITHUB_ACTOR"
|
||||
git config user.email "$GITHUB_ACTOR@users.noreply.github.com"
|
||||
git add deploy/helm/pulse/Chart.yaml
|
||||
git commit -m "Auto-update Helm chart version to $VERSION"
|
||||
git pull --rebase origin "$REQUIRED_BRANCH"
|
||||
git push origin HEAD:"$REQUIRED_BRANCH"
|
||||
fi
|
||||
|
||||
- name: Validate Helm chart
|
||||
run: |
|
||||
# Strict linting
|
||||
@@ -306,6 +266,30 @@ jobs:
|
||||
git -C "${workdir}/gh-pages" push origin HEAD:gh-pages
|
||||
fi
|
||||
|
||||
public_repo="https://rcourtman.github.io/Pulse"
|
||||
public_ready=false
|
||||
for attempt in $(seq 1 12); do
|
||||
public_index="${workdir}/public-index.yaml"
|
||||
if curl -fsSL --retry 3 --retry-delay 2 --retry-all-errors \
|
||||
-o "$public_index" "${public_repo}/index.yaml" && \
|
||||
grep -q "version: ${VERSION}" "$public_index"; then
|
||||
helm repo remove pulse-public >/dev/null 2>&1 || true
|
||||
helm repo add pulse-public "$public_repo" --force-update
|
||||
helm repo update pulse-public
|
||||
if helm show chart pulse-public/pulse --version "$VERSION" >/dev/null; then
|
||||
public_ready=true
|
||||
break
|
||||
fi
|
||||
fi
|
||||
echo "Public Helm repository has not exposed ${VERSION} yet (attempt ${attempt}/12)."
|
||||
sleep 5
|
||||
done
|
||||
if [ "$public_ready" != "true" ]; then
|
||||
echo "::error::Public Helm repository did not expose chart ${VERSION}."
|
||||
exit 1
|
||||
fi
|
||||
echo "[OK] Public Helm repository serves pulse ${VERSION}."
|
||||
|
||||
- name: Mark Helm chart release as pre-release (avoid latest override)
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: install.sh Smoke (Published Release)
|
||||
name: install.sh Smoke (Release Assets)
|
||||
|
||||
# End-to-end smoke that exercises the documented Proxmox-LXC / systemd
|
||||
# install flow against the published GitHub Release. Across v6 rc.1 → rc.5
|
||||
# install flow against staged or published GitHub Release assets. Across v6 rc.1 → rc.5
|
||||
# the published install.sh asset was actually the agent installer (rejecting
|
||||
# --version) and the README's pinned signature key did not match the
|
||||
# pipeline's actual signing key, so anyone running the secure-install
|
||||
@@ -17,7 +17,8 @@ name: install.sh Smoke (Published Release)
|
||||
#
|
||||
# What it does:
|
||||
# 1. Downloads install.sh + install.sh.sshsig + the linux-amd64 tarball
|
||||
# from the published release URL (not the local release/ dir).
|
||||
# from the authenticated draft-release API during a release cut, or from
|
||||
# the public release URL during a manual post-publication check.
|
||||
# 2. Extracts the README's pinned ed25519 key and runs the README's exact
|
||||
# ssh-keygen -Y verify command. Catches README/key drift.
|
||||
# 3. Boots a privileged systemd Debian 12 container, runs
|
||||
@@ -40,7 +41,17 @@ on:
|
||||
required: true
|
||||
type: string
|
||||
repository:
|
||||
description: 'owner/repo to pull the published release from. Defaults to the workflow repository.'
|
||||
description: 'owner/repo to pull the release from. Defaults to the workflow repository.'
|
||||
required: false
|
||||
type: string
|
||||
default: ''
|
||||
asset_source:
|
||||
description: 'Asset source: staged for a draft release, or published for the public release URL.'
|
||||
required: false
|
||||
type: string
|
||||
default: 'published'
|
||||
release_id:
|
||||
description: 'Draft release ID. Required when asset_source is staged.'
|
||||
required: false
|
||||
type: string
|
||||
default: ''
|
||||
@@ -59,6 +70,19 @@ on:
|
||||
required: false
|
||||
type: string
|
||||
default: ''
|
||||
asset_source:
|
||||
description: 'Asset source'
|
||||
required: false
|
||||
type: choice
|
||||
options:
|
||||
- published
|
||||
- staged
|
||||
default: published
|
||||
release_id:
|
||||
description: 'Draft release ID (required for staged assets)'
|
||||
required: false
|
||||
type: string
|
||||
default: ''
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -80,6 +104,8 @@ jobs:
|
||||
INPUT_TAG: ${{ inputs.tag }}
|
||||
INPUT_VERSION: ${{ inputs.version }}
|
||||
INPUT_REPO: ${{ inputs.repository }}
|
||||
INPUT_ASSET_SOURCE: ${{ inputs.asset_source }}
|
||||
INPUT_RELEASE_ID: ${{ inputs.release_id }}
|
||||
DEFAULT_REPO: ${{ github.repository }}
|
||||
run: |
|
||||
# workflow_call and workflow_dispatch both require tag + version,
|
||||
@@ -91,35 +117,78 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
repo="${INPUT_REPO:-$DEFAULT_REPO}"
|
||||
asset_source="${INPUT_ASSET_SOURCE:-published}"
|
||||
release_id="${INPUT_RELEASE_ID:-}"
|
||||
case "$asset_source" in
|
||||
published) ;;
|
||||
staged)
|
||||
if [ -z "$release_id" ]; then
|
||||
echo "::error::release_id is required when asset_source=staged"
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "::error::asset_source must be staged or published, got: $asset_source"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
echo "repo=$repo" >> "$GITHUB_OUTPUT"
|
||||
echo "Resolved: tag=$tag version=$version repo=$repo"
|
||||
echo "asset_source=$asset_source" >> "$GITHUB_OUTPUT"
|
||||
echo "release_id=$release_id" >> "$GITHUB_OUTPUT"
|
||||
echo "Resolved: tag=$tag version=$version repo=$repo asset_source=$asset_source release_id=${release_id:-none}"
|
||||
|
||||
- name: Download published install.sh + sshsig + linux-amd64 tarball
|
||||
- name: Download install.sh + sshsig + linux-amd64 tarball
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ steps.inputs.outputs.tag }}
|
||||
REPO: ${{ steps.inputs.outputs.repo }}
|
||||
ASSET_SOURCE: ${{ steps.inputs.outputs.asset_source }}
|
||||
RELEASE_ID: ${{ steps.inputs.outputs.release_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p smoke-workspace
|
||||
cd smoke-workspace
|
||||
base="https://github.com/${REPO}/releases/download/${TAG}"
|
||||
echo "Pulling from ${base}/"
|
||||
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors \
|
||||
-o install.sh "${base}/install.sh"
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors \
|
||||
-o install.sh.sshsig "${base}/install.sh.sshsig"
|
||||
|
||||
tarball="pulse-${TAG}-linux-amd64.tar.gz"
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors \
|
||||
-o "${tarball}" "${base}/${tarball}"
|
||||
# The .sshsig sidecar must travel with the tarball: install.sh
|
||||
# --archive now requires it for signature verification before
|
||||
# extraction, closing the local-tarball-swap window.
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors \
|
||||
-o "${tarball}.sshsig" "${base}/${tarball}.sshsig"
|
||||
assets=(install.sh install.sh.sshsig "${tarball}" "${tarball}.sshsig")
|
||||
|
||||
if [ "$ASSET_SOURCE" = "staged" ]; then
|
||||
assets_json=$(mktemp)
|
||||
gh api --paginate "repos/${REPO}/releases/${RELEASE_ID}/assets?per_page=100" \
|
||||
| jq -s 'add' > "$assets_json"
|
||||
|
||||
release_state=$(gh api "repos/${REPO}/releases/${RELEASE_ID}" \
|
||||
--jq '[.tag_name, (.draft | tostring), (.published_at // "")] | @tsv')
|
||||
actual_tag=$(awk -F '\t' '{print $1}' <<<"$release_state")
|
||||
is_draft=$(awk -F '\t' '{print $2}' <<<"$release_state")
|
||||
published_at=$(awk -F '\t' '{print $3}' <<<"$release_state")
|
||||
if [ "$actual_tag" != "$TAG" ] || [ "$is_draft" != "true" ] || [ -n "$published_at" ]; then
|
||||
echo "::error::Release ${RELEASE_ID} is not the unpublished draft for ${TAG}."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for asset_name in "${assets[@]}"; do
|
||||
asset_id=$(jq -r --arg name "$asset_name" \
|
||||
'map(select(.name == $name)) | if length == 1 then .[0].id else empty end' \
|
||||
"$assets_json")
|
||||
if [ -z "$asset_id" ]; then
|
||||
echo "::error::Draft release ${RELEASE_ID} does not contain exactly one ${asset_name} asset."
|
||||
exit 1
|
||||
fi
|
||||
gh api \
|
||||
-H 'Accept: application/octet-stream' \
|
||||
"repos/${REPO}/releases/assets/${asset_id}" > "$asset_name"
|
||||
done
|
||||
rm -f "$assets_json"
|
||||
else
|
||||
base="https://github.com/${REPO}/releases/download/${TAG}"
|
||||
echo "Pulling from ${base}/"
|
||||
for asset_name in "${assets[@]}"; do
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors \
|
||||
-o "$asset_name" "${base}/${asset_name}"
|
||||
done
|
||||
fi
|
||||
|
||||
echo "Downloaded:"
|
||||
ls -la
|
||||
|
||||
@@ -1,24 +1,16 @@
|
||||
name: Promote Floating Tags
|
||||
run-name: Promote Floating Tags ${{ inputs.tag }}
|
||||
|
||||
# Promotes floating tags for the Pulse server image after a release. Only
|
||||
# rcourtman/pulse + ghcr.io/<owner>/pulse are promoted; the Pulse agent
|
||||
# ships as GitHub Release binaries (see publish-docker.yml line 199), not
|
||||
# as a Docker image, so there are no agent floating tags to promote.
|
||||
# Promotes floating tags for the Pulse server and control-plane images at the
|
||||
# release activation barrier. The exact-version images are staged first by
|
||||
# publish-docker.yml; this workflow is the sole owner of mutable aliases.
|
||||
#
|
||||
# Triggers:
|
||||
# - workflow_run: chains off publish-docker.yml completion. Historically
|
||||
# this was the only path; it remains the default for releases that go
|
||||
# straight through publish-docker.yml.
|
||||
# - workflow_call: called explicitly from create-release.yml after
|
||||
# validate_release_assets succeeds. Defensive backup so a workflow_run
|
||||
# failure doesn't silently leave latest/major/minor tags unpromoted.
|
||||
# - workflow_call: called explicitly from create-release.yml after the exact
|
||||
# version images and release assets pass their staging checks.
|
||||
# - workflow_dispatch: manual backfill path.
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Publish Docker Images"]
|
||||
types: [completed]
|
||||
workflow_call:
|
||||
inputs:
|
||||
tag:
|
||||
@@ -52,9 +44,6 @@ permissions:
|
||||
jobs:
|
||||
promote-images:
|
||||
runs-on: ubuntu-24.04
|
||||
# workflow_dispatch and workflow_call always run; workflow_run only when
|
||||
# the triggering Publish Docker Images run completed successfully.
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call' || github.event.workflow_run.conclusion == 'success' }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
@@ -67,40 +56,11 @@ jobs:
|
||||
INPUT_TAG: ${{ inputs.tag }}
|
||||
INPUT_PRERELEASE: ${{ inputs.prerelease }}
|
||||
run: |
|
||||
# inputs.tag is set by both workflow_dispatch and workflow_call. Fall
|
||||
# back to the workflow_run derivation only when running as a chain
|
||||
# downstream of publish-docker.yml.
|
||||
if [ -n "${INPUT_TAG}" ]; then
|
||||
TAG="${INPUT_TAG}"
|
||||
PRERELEASE="${INPUT_PRERELEASE}"
|
||||
else
|
||||
# workflow_run trigger - extract from the triggering workflow's inputs
|
||||
# The publish-docker workflow was triggered with a tag input
|
||||
RUN_ID="${{ github.event.workflow_run.id }}"
|
||||
echo "Extracting inputs from workflow run ${RUN_ID}..."
|
||||
|
||||
# Get the workflow run details to extract the tag
|
||||
WORKFLOW_DATA=$(gh api repos/${{ github.repository }}/actions/runs/${RUN_ID})
|
||||
TAG=$(echo "$WORKFLOW_DATA" | jq -r '.head_branch // ""')
|
||||
|
||||
# If head_branch is main, we need to get it from the run's inputs
|
||||
# The inputs are stored in the run's display_title or we parse from artifacts
|
||||
if [ "$TAG" = "main" ] || [ -z "$TAG" ]; then
|
||||
# Try to get from run name which typically includes the tag
|
||||
TAG=$(echo "$WORKFLOW_DATA" | jq -r '.display_title' | grep -oP 'v\d+\.\d+\.\d+(-[a-zA-Z]+\.\d+)?' || echo "")
|
||||
fi
|
||||
|
||||
if [ -z "$TAG" ]; then
|
||||
echo "::error::Could not extract tag from workflow_run"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Detect prerelease from tag
|
||||
if [[ "$TAG" =~ -rc\.[0-9]+$ ]] || [[ "$TAG" =~ -alpha\.[0-9]+$ ]] || [[ "$TAG" =~ -beta\.[0-9]+$ ]]; then
|
||||
PRERELEASE="true"
|
||||
else
|
||||
PRERELEASE="false"
|
||||
fi
|
||||
TAG="${INPUT_TAG}"
|
||||
PRERELEASE="${INPUT_PRERELEASE}"
|
||||
if [ -z "$TAG" ]; then
|
||||
echo "::error::tag is required"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "tag=${TAG}" >> $GITHUB_OUTPUT
|
||||
@@ -135,26 +95,29 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Wait for Pulse server image to be available
|
||||
- name: Wait for exact-version images to be available
|
||||
env:
|
||||
TAG: ${{ steps.extract.outputs.tag }}
|
||||
run: |
|
||||
echo "Waiting for rcourtman/pulse:${TAG} to be available..."
|
||||
MAX_ATTEMPTS=30
|
||||
ATTEMPT=0
|
||||
while [ $ATTEMPT -lt $MAX_ATTEMPTS ]; do
|
||||
if docker manifest inspect rcourtman/pulse:${TAG} > /dev/null 2>&1; then
|
||||
echo "Image rcourtman/pulse:${TAG} is available!"
|
||||
exit 0
|
||||
set -euo pipefail
|
||||
for image in pulse pulse-control-plane; do
|
||||
echo "Waiting for rcourtman/${image}:${TAG} to be available..."
|
||||
available=false
|
||||
for attempt in $(seq 1 30); do
|
||||
if docker manifest inspect "rcourtman/${image}:${TAG}" > /dev/null 2>&1; then
|
||||
echo "Image rcourtman/${image}:${TAG} is available."
|
||||
available=true
|
||||
break
|
||||
fi
|
||||
echo "Attempt ${attempt}/30 - ${image} image not yet available, waiting 10s..."
|
||||
sleep 10
|
||||
done
|
||||
if [ "$available" != "true" ]; then
|
||||
echo "::error::Timeout waiting for rcourtman/${image}:${TAG}"
|
||||
exit 1
|
||||
fi
|
||||
ATTEMPT=$((ATTEMPT + 1))
|
||||
echo "Attempt $ATTEMPT/$MAX_ATTEMPTS - pulse image not yet available, waiting 10s..."
|
||||
sleep 10
|
||||
done
|
||||
|
||||
echo "Timeout waiting for pulse Docker image"
|
||||
exit 1
|
||||
|
||||
- name: Promote Pulse server image tags
|
||||
env:
|
||||
TAG: ${{ steps.extract.outputs.tag }}
|
||||
@@ -169,40 +132,41 @@ jobs:
|
||||
MINOR=${MINOR:-0}
|
||||
MAJOR_MINOR="$MAJOR.$MINOR"
|
||||
|
||||
if [ "$PRERELEASE" = "true" ]; then
|
||||
echo "Promoting prerelease tags for ${TAG}"
|
||||
docker buildx imagetools create \
|
||||
-t rcourtman/pulse:rc \
|
||||
rcourtman/pulse:${TAG}
|
||||
docker buildx imagetools create \
|
||||
-t ghcr.io/${OWNER}/pulse:rc \
|
||||
ghcr.io/${OWNER}/pulse:${TAG}
|
||||
else
|
||||
# :latest belongs to the highest stable semver overall. A
|
||||
# maintenance cut of an older line (e.g. v5.1.36 after v6 GA, or a
|
||||
# 6.0.x patch after 6.1 ships) must only move its own :MAJOR and
|
||||
# :MAJOR.MINOR tags.
|
||||
HIGHEST_STABLE=$(git tag -l 'v*' | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1)
|
||||
LATEST_ARGS=""
|
||||
LATEST_ARGS_GHCR=""
|
||||
HIGHEST_STABLE=$(git tag -l 'v*' | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1)
|
||||
for image in pulse pulse-control-plane; do
|
||||
if [ "$PRERELEASE" = "true" ]; then
|
||||
echo "Promoting :rc for ${image} from ${TAG}."
|
||||
docker buildx imagetools create \
|
||||
-t "rcourtman/${image}:rc" \
|
||||
"rcourtman/${image}:${TAG}"
|
||||
docker buildx imagetools create \
|
||||
-t "ghcr.io/${OWNER}/${image}:rc" \
|
||||
"ghcr.io/${OWNER}/${image}:${TAG}"
|
||||
continue
|
||||
fi
|
||||
|
||||
docker_hub_tags=(
|
||||
-t "rcourtman/${image}:${MAJOR_MINOR}"
|
||||
-t "rcourtman/${image}:${MAJOR}"
|
||||
)
|
||||
ghcr_tags=(
|
||||
-t "ghcr.io/${OWNER}/${image}:${MAJOR_MINOR}"
|
||||
-t "ghcr.io/${OWNER}/${image}:${MAJOR}"
|
||||
)
|
||||
if [ "$TAG" = "$HIGHEST_STABLE" ] || [ "${FORCE_LATEST}" = "true" ]; then
|
||||
LATEST_ARGS="-t rcourtman/pulse:latest"
|
||||
LATEST_ARGS_GHCR="-t ghcr.io/${OWNER}/pulse:latest"
|
||||
echo "Promoting stable tags for ${TAG} (including :latest)"
|
||||
docker_hub_tags+=( -t "rcourtman/${image}:latest" )
|
||||
ghcr_tags+=( -t "ghcr.io/${OWNER}/${image}:latest" )
|
||||
echo "Promoting stable aliases for ${image} from ${TAG}, including :latest."
|
||||
else
|
||||
echo "Promoting stable tags for ${TAG} WITHOUT :latest (highest stable is ${HIGHEST_STABLE})"
|
||||
echo "Promoting stable aliases for ${image} from ${TAG} without :latest (highest stable is ${HIGHEST_STABLE})."
|
||||
fi
|
||||
docker buildx imagetools create \
|
||||
${LATEST_ARGS} \
|
||||
-t rcourtman/pulse:${MAJOR_MINOR} \
|
||||
-t rcourtman/pulse:${MAJOR} \
|
||||
rcourtman/pulse:${TAG}
|
||||
"${docker_hub_tags[@]}" \
|
||||
"rcourtman/${image}:${TAG}"
|
||||
docker buildx imagetools create \
|
||||
${LATEST_ARGS_GHCR} \
|
||||
-t ghcr.io/${OWNER}/pulse:${MAJOR_MINOR} \
|
||||
-t ghcr.io/${OWNER}/pulse:${MAJOR} \
|
||||
ghcr.io/${OWNER}/pulse:${TAG}
|
||||
fi
|
||||
"${ghcr_tags[@]}" \
|
||||
"ghcr.io/${OWNER}/${image}:${TAG}"
|
||||
done
|
||||
|
||||
- name: Promotion summary
|
||||
env:
|
||||
@@ -215,8 +179,8 @@ jobs:
|
||||
MAJOR_MINOR="$MAJOR.${MINOR:-0}"
|
||||
|
||||
if [ "$PRERELEASE" = "true" ]; then
|
||||
echo "✅ Updated :rc tag to point to ${TAG} for the Pulse server image."
|
||||
echo "✅ Updated :rc to point to ${TAG} for the server and control-plane images."
|
||||
else
|
||||
echo "✅ Updated :latest, :${MAJOR_MINOR}, :${MAJOR} tags to point to ${TAG} for the Pulse server image."
|
||||
echo "✅ Updated stable floating aliases for the server and control-plane images to ${TAG}."
|
||||
fi
|
||||
echo "Pulse Agent ships as GitHub Release binaries, not a Docker image."
|
||||
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
IS_PRERELEASE="false"
|
||||
if [[ "$VERSION" =~ -rc\.[0-9]+$ ]] || [[ "$VERSION" =~ -alpha\.[0-9]+$ ]] || [[ "$VERSION" =~ -beta\.[0-9]+$ ]]; then
|
||||
IS_PRERELEASE="true"
|
||||
echo "Detected prerelease version - will NOT update :latest tag"
|
||||
echo "Detected prerelease version"
|
||||
fi
|
||||
|
||||
echo "tag=${TAG}" >> $GITHUB_OUTPUT
|
||||
@@ -129,10 +129,8 @@ jobs:
|
||||
tags: |
|
||||
rcourtman/pulse:${{ steps.version.outputs.tag }}
|
||||
rcourtman/pulse:${{ steps.version.outputs.version }}
|
||||
${{ steps.version.outputs.is_prerelease != 'true' && 'rcourtman/pulse:latest' || '' }}
|
||||
ghcr.io/${{ github.repository_owner }}/pulse:${{ steps.version.outputs.tag }}
|
||||
ghcr.io/${{ github.repository_owner }}/pulse:${{ steps.version.outputs.version }}
|
||||
${{ steps.version.outputs.is_prerelease != 'true' && format('ghcr.io/{0}/pulse:latest', github.repository_owner) || '' }}
|
||||
|
||||
- name: Attest Pulse server image on Docker Hub
|
||||
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
|
||||
@@ -169,10 +167,8 @@ jobs:
|
||||
tags: |
|
||||
rcourtman/pulse-control-plane:${{ steps.version.outputs.tag }}
|
||||
rcourtman/pulse-control-plane:${{ steps.version.outputs.version }}
|
||||
${{ steps.version.outputs.is_prerelease != 'true' && 'rcourtman/pulse-control-plane:latest' || '' }}
|
||||
ghcr.io/${{ github.repository_owner }}/pulse-control-plane:${{ steps.version.outputs.tag }}
|
||||
ghcr.io/${{ github.repository_owner }}/pulse-control-plane:${{ steps.version.outputs.version }}
|
||||
${{ steps.version.outputs.is_prerelease != 'true' && format('ghcr.io/{0}/pulse-control-plane:latest', github.repository_owner) || '' }}
|
||||
|
||||
- name: Attest Pulse control-plane image on Docker Hub
|
||||
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
|
||||
@@ -192,26 +188,16 @@ jobs:
|
||||
|
||||
- name: Output image information
|
||||
run: |
|
||||
IS_PRERELEASE="${{ steps.version.outputs.is_prerelease }}"
|
||||
echo "✅ Docker images published successfully!"
|
||||
echo "✅ Exact-version Docker images staged successfully!"
|
||||
echo ""
|
||||
echo "Server images (linux/amd64, linux/arm64):"
|
||||
echo " - rcourtman/pulse:${{ steps.version.outputs.tag }}"
|
||||
echo " - rcourtman/pulse:${{ steps.version.outputs.version }}"
|
||||
if [ "$IS_PRERELEASE" != "true" ]; then
|
||||
echo " - rcourtman/pulse:latest"
|
||||
fi
|
||||
echo ""
|
||||
echo "Control-plane images (linux/amd64, linux/arm64):"
|
||||
echo " - rcourtman/pulse-control-plane:${{ steps.version.outputs.tag }}"
|
||||
echo " - rcourtman/pulse-control-plane:${{ steps.version.outputs.version }}"
|
||||
if [ "$IS_PRERELEASE" != "true" ]; then
|
||||
echo " - rcourtman/pulse-control-plane:latest"
|
||||
fi
|
||||
echo ""
|
||||
echo "Pulse Agent binaries ship as release assets, not as a Docker image."
|
||||
echo "See the GitHub release page for pulse-agent-{darwin,freebsd,linux,windows}-{amd64,arm64,...}."
|
||||
echo ""
|
||||
if [ "$IS_PRERELEASE" = "true" ]; then
|
||||
echo "Note: :latest tags were NOT updated (this is a prerelease)"
|
||||
fi
|
||||
echo "Floating aliases are promoted separately at the activation barrier."
|
||||
|
||||
@@ -17,6 +17,11 @@ on:
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
release_id:
|
||||
description: 'Optional staged GitHub release ID used before customer activation'
|
||||
required: false
|
||||
default: ''
|
||||
type: string
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
@@ -36,6 +41,11 @@ on:
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
release_id:
|
||||
description: 'Optional staged GitHub release ID'
|
||||
required: false
|
||||
default: ''
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -193,9 +203,43 @@ jobs:
|
||||
|
||||
- name: Wait for release assets
|
||||
if: inputs.verify_only != true
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_ID: ${{ inputs.release_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${{ needs.resolve.outputs.tag }}"
|
||||
if [ -n "${RELEASE_ID:-}" ]; then
|
||||
release_state=$(gh api "repos/${{ github.repository }}/releases/${RELEASE_ID}" \
|
||||
--jq '[.tag_name, (.draft | tostring), (.published_at // "")] | @tsv')
|
||||
actual_tag=$(awk -F '\t' '{print $1}' <<<"$release_state")
|
||||
is_draft=$(awk -F '\t' '{print $2}' <<<"$release_state")
|
||||
published_at=$(awk -F '\t' '{print $3}' <<<"$release_state")
|
||||
if [ "$actual_tag" != "$TAG" ] || [ "$is_draft" != "true" ] || [ -n "$published_at" ]; then
|
||||
echo "::error::Release ${RELEASE_ID} is not the unpublished draft for ${TAG}."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
assets_json=$(mktemp)
|
||||
gh api --paginate "repos/${{ github.repository }}/releases/${RELEASE_ID}/assets?per_page=100" \
|
||||
| jq -s 'add' > "$assets_json"
|
||||
tarball="pulse-${TAG}-linux-amd64.tar.gz"
|
||||
for asset_name in "$tarball" "${tarball}.sshsig"; do
|
||||
asset_id=$(jq -r --arg name "$asset_name" \
|
||||
'map(select(.name == $name)) | if length == 1 then .[0].id else empty end' \
|
||||
"$assets_json")
|
||||
if [ -z "$asset_id" ]; then
|
||||
echo "::error::Draft release ${RELEASE_ID} does not contain exactly one ${asset_name} asset."
|
||||
exit 1
|
||||
fi
|
||||
gh api -H 'Accept: application/octet-stream' \
|
||||
"repos/${{ github.repository }}/releases/assets/${asset_id}" > "/tmp/${asset_name}"
|
||||
done
|
||||
rm -f "$assets_json"
|
||||
echo "Staged release assets are available for the demo update."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Waiting for release assets to be available..."
|
||||
|
||||
MAX_ATTEMPTS=30
|
||||
@@ -428,15 +472,24 @@ jobs:
|
||||
env:
|
||||
DEMO_SERVER_HOST: ${{ secrets.DEMO_SERVER_HOST }}
|
||||
DEMO_SERVER_USER: ${{ secrets.DEMO_SERVER_USER }}
|
||||
RELEASE_ID: ${{ inputs.release_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
scp -i ~/.ssh/id_ed25519 /tmp/pulse-install.sh "$DEMO_SERVER_USER@$DEMO_SERVER_HOST:/tmp/pulse-install.sh"
|
||||
if [ -n "${RELEASE_ID:-}" ]; then
|
||||
TAG="${{ needs.resolve.outputs.tag }}"
|
||||
tarball="pulse-${TAG}-linux-amd64.tar.gz"
|
||||
scp -i ~/.ssh/id_ed25519 \
|
||||
"/tmp/${tarball}" "/tmp/${tarball}.sshsig" \
|
||||
"$DEMO_SERVER_USER@$DEMO_SERVER_HOST:/tmp/"
|
||||
fi
|
||||
|
||||
- name: Update demo server
|
||||
if: inputs.verify_only != true && steps.current.outputs.skip_current != 'true'
|
||||
env:
|
||||
DEMO_SERVER_HOST: ${{ secrets.DEMO_SERVER_HOST }}
|
||||
DEMO_SERVER_USER: ${{ secrets.DEMO_SERVER_USER }}
|
||||
RELEASE_ID: ${{ inputs.release_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${{ needs.resolve.outputs.tag }}"
|
||||
@@ -445,15 +498,28 @@ jobs:
|
||||
set -euo pipefail
|
||||
TAG="$1"
|
||||
SERVICE_NAME="$2"
|
||||
USE_STAGED_ARCHIVE="$3"
|
||||
INSTALLER_ENV=()
|
||||
if [ -n "$SERVICE_NAME" ]; then
|
||||
INSTALLER_ENV+=("PULSE_SERVICE_NAME=$SERVICE_NAME")
|
||||
fi
|
||||
sudo env "${INSTALLER_ENV[@]}" bash /tmp/pulse-install.sh --version "$TAG"
|
||||
if [ "$USE_STAGED_ARCHIVE" = "true" ]; then
|
||||
tarball="pulse-${TAG}-linux-amd64.tar.gz"
|
||||
sudo env "${INSTALLER_ENV[@]}" bash /tmp/pulse-install.sh \
|
||||
--archive "/tmp/${tarball}" --disable-auto-updates
|
||||
rm -f "/tmp/${tarball}" "/tmp/${tarball}.sshsig"
|
||||
else
|
||||
sudo env "${INSTALLER_ENV[@]}" bash /tmp/pulse-install.sh --version "$TAG"
|
||||
fi
|
||||
rm -f /tmp/pulse-install.sh
|
||||
EOF
|
||||
)
|
||||
ssh -i ~/.ssh/id_ed25519 "$DEMO_SERVER_USER@$DEMO_SERVER_HOST" "bash -s -- $(printf '%q ' "$TAG" "$SERVICE_NAME")" <<<"$REMOTE_SCRIPT"
|
||||
use_staged_archive=false
|
||||
if [ -n "${RELEASE_ID:-}" ]; then
|
||||
use_staged_archive=true
|
||||
fi
|
||||
ssh -i ~/.ssh/id_ed25519 "$DEMO_SERVER_USER@$DEMO_SERVER_HOST" \
|
||||
"bash -s -- $(printf '%q ' "$TAG" "$SERVICE_NAME" "$use_staged_archive")" <<<"$REMOTE_SCRIPT"
|
||||
|
||||
- name: Restore demo runtime configuration
|
||||
if: inputs.verify_only != true
|
||||
|
||||
Reference in New Issue
Block a user