mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +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
|
||||
|
||||
@@ -437,13 +437,16 @@ upgrade, update, release, or artifact-selection behavior.
|
||||
## Extension Points
|
||||
|
||||
1. Add or change deployment-type detection, update planning, or apply behavior through `internal/updates/`
|
||||
2. Add or change release-build metadata injection, Docker build-context allowlists, release artifact assembly, governed promotion metadata resolution, artifact release-line validation, post-install live-runtime claim proof, the canonical version file, operator-facing release packet content, prerelease feedback intake wording, historical published-release integrity backfill, release asset validation status publication, download endpoint checksum/signature header proof, end-to-end install.sh smoke against the published release, or the canonical in-repo v6 upgrade guide through `scripts/build-release.sh`, `scripts/release_asset_common.sh`, `scripts/backfill-release-assets.sh`, `scripts/release_ldflags.sh`, `scripts/check-workflow-dispatch-inputs.py`, `scripts/release_control/live_runtime_proof.py`, `scripts/release_control/live_runtime_proof_test.py`, `scripts/release_control/mobile_release_gate.py`, `scripts/release_control/render_release_body.py`, `scripts/release_control/resolve_release_promotion.py`, `scripts/release_control/validate_artifact_release_line.py`, `scripts/release_control/record_rc_to_ga_rehearsal.py`, `scripts/release_control/internal/record_rc_to_ga_rehearsal.py`, `scripts/release_control/release_promotion_policy_support.py`, `.dockerignore`, `Dockerfile`, `.github/ISSUE_TEMPLATE/v6_rc_feedback.yml`, `docs/RELEASE_NOTES.md`, `docs/releases/`, `docs/UPGRADE_v6.md`, `docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md`, `docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md`, `docs/release-control/v6/internal/RC_TO_GA_REHEARSAL_TEMPLATE.md`, `scripts/validate-release.sh`, `scripts/validate-published-release.sh`, the operator dispatch helpers `scripts/trigger-release.sh` and `scripts/trigger-release-dry-run.sh`, and the governed release workflows `.github/workflows/backfill-release-assets.yml`, `.github/workflows/create-release.yml`, `.github/workflows/deploy-demo-server.yml`, `.github/workflows/helm-pages.yml`, `.github/workflows/install-sh-smoke.yml`, `.github/workflows/publish-docker.yml`, `.github/workflows/publish-helm-chart.yml`, `.github/workflows/promote-floating-tags.yml`, `.github/workflows/release-dry-run.yml`, `.github/workflows/update-demo-server.yml`, and `.github/workflows/validate-release-assets.yml`
|
||||
2. Add or change release-build metadata injection, Docker build-context allowlists, release artifact assembly, governed promotion metadata resolution, artifact release-line validation, post-install live-runtime claim proof, the canonical version file, operator-facing release packet content, prerelease feedback intake wording, historical published-release integrity backfill, release asset validation status publication, download endpoint checksum/signature header proof, end-to-end install.sh smoke against staged or published release assets, or the canonical in-repo v6 upgrade guide through `scripts/build-release.sh`, `scripts/release_asset_common.sh`, `scripts/backfill-release-assets.sh`, `scripts/release_ldflags.sh`, `scripts/check-workflow-dispatch-inputs.py`, `scripts/release_control/live_runtime_proof.py`, `scripts/release_control/live_runtime_proof_test.py`, `scripts/release_control/mobile_release_gate.py`, `scripts/release_control/render_release_body.py`, `scripts/release_control/resolve_release_promotion.py`, `scripts/release_control/validate_artifact_release_line.py`, `scripts/release_control/record_rc_to_ga_rehearsal.py`, `scripts/release_control/internal/record_rc_to_ga_rehearsal.py`, `scripts/release_control/release_promotion_policy_support.py`, `.dockerignore`, `Dockerfile`, `.github/ISSUE_TEMPLATE/v6_rc_feedback.yml`, `docs/RELEASE_NOTES.md`, `docs/releases/`, `docs/UPGRADE_v6.md`, `docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md`, `docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md`, `docs/release-control/v6/internal/RC_TO_GA_REHEARSAL_TEMPLATE.md`, `scripts/validate-release.sh`, `scripts/validate-published-release.sh`, the operator dispatch helpers `scripts/trigger-release.sh` and `scripts/trigger-release-dry-run.sh`, and the governed release workflows `.github/workflows/backfill-release-assets.yml`, `.github/workflows/create-release.yml`, `.github/workflows/deploy-demo-server.yml`, `.github/workflows/helm-pages.yml`, `.github/workflows/install-sh-smoke.yml`, `.github/workflows/publish-docker.yml`, `.github/workflows/publish-helm-chart.yml`, `.github/workflows/promote-floating-tags.yml`, `.github/workflows/release-dry-run.yml`, `.github/workflows/update-demo-server.yml`, and `.github/workflows/validate-release-assets.yml`
|
||||
Normal releases are single-build promotions. The exact pushed SHA must
|
||||
produce one release candidate with the policy-required native signing lanes
|
||||
through `.github/workflows/build-release-candidate.yml` while independent
|
||||
release checks run in parallel. `create-release.yml` may publish only that
|
||||
release checks run in parallel. `create-release.yml` may stage only that
|
||||
candidate after `scripts/release_candidate_manifest.py` verifies its version, source
|
||||
SHA, filenames, sizes, and SHA-256 values. Standard post-upload validation
|
||||
SHA, filenames, sizes, and SHA-256 values. The GitHub release must remain an
|
||||
unpublished draft until every exact-version customer artifact and mutable
|
||||
customer pointer required by the cut has passed its owned proof. Standard
|
||||
post-upload validation
|
||||
must compare that manifest with GitHub's server-side asset digests instead
|
||||
of downloading the complete release packet again. Historical repair and
|
||||
release-edit validation may use the full-download fallback because those
|
||||
@@ -487,9 +490,11 @@ upgrade, update, release, or artifact-selection behavior.
|
||||
client-specific release artifact, and full-surface token guidance must come
|
||||
from the manifest-owned `requiredScopes` list so release notes cannot drift
|
||||
away from the shipped adapter.
|
||||
The `install-sh-smoke.yml` workflow runs end-to-end against the
|
||||
published release in a privileged systemd container: it downloads
|
||||
`install.sh` and `install.sh.sshsig` from the GitHub Release URL,
|
||||
The `install-sh-smoke.yml` workflow runs end-to-end against staged or
|
||||
published release assets in a privileged systemd container. During a
|
||||
release cut it downloads the exact draft assets through the authenticated
|
||||
GitHub Release API; manual post-publication checks may use the public release
|
||||
URL. It downloads `install.sh` and `install.sh.sshsig`,
|
||||
runs the README-documented `ssh-keygen -Y verify` step against the
|
||||
real signed asset using the README's pinned key, re-checks the
|
||||
server-installer banner / `--version)` arg handler / agent-banner
|
||||
@@ -498,13 +503,13 @@ upgrade, update, release, or artifact-selection behavior.
|
||||
inside the container and asserts `systemctl is-active pulse`, a 200
|
||||
from `/api/health`, and a version match from `/api/version`.
|
||||
`create-release.yml` must call this workflow as a downstream
|
||||
`workflow_call` after `validate-release-assets.yml` succeeds for every
|
||||
release that is not a `historical_asset_backfill_only` run; without
|
||||
`workflow_call` after `validate-release-assets.yml` succeeds and before
|
||||
customer activation for every release that is neither a draft-only nor a
|
||||
`historical_asset_backfill_only` run; without
|
||||
that wiring the smoke gate exists but never protects a release. Draft-only
|
||||
release runs are not a publication boundary and must skip downstream
|
||||
install smoke, Helm chart publication, and floating tag promotion because
|
||||
draft assets are not publicly downloadable and those publish steps would
|
||||
advance externally visible state before operator publication.
|
||||
release runs are not a publication boundary and must stop after staged
|
||||
release validation, skipping the install smoke, Helm chart staging, mutable
|
||||
pointer promotion, private Pro publication, and final activation sequence.
|
||||
The README's pinned `pulse-installer` ed25519 key must verify
|
||||
`install.sh.sshsig` for the published release; this is enforced by
|
||||
`scripts/validate-release.sh` at build time and re-verified by
|
||||
@@ -893,12 +898,16 @@ upgrade, update, release, or artifact-selection behavior.
|
||||
must never show the community `rcourtman/pulse` pull commands when the
|
||||
compiled runtime is Pro.
|
||||
Customer-facing private Pro RC/GA promotion is part of that same boundary:
|
||||
for every non-draft v6 public release, `create-release.yml` must call the
|
||||
private `rcourtman/pulse-enterprise` `Build Pro Release` workflow after
|
||||
`validate_release_assets` succeeds, pass the exact public tag/version, set
|
||||
`upload_to_r2=true` and `publish_docker_image=true`, wait for that workflow
|
||||
to succeed, then call the private `rcourtman/pulse-pro`
|
||||
`Promote Paid Runtime Release` workflow with the same version and R2 prefix.
|
||||
for every non-draft v6 release, `create-release.yml` must call the private
|
||||
`rcourtman/pulse-enterprise` `Build Pro Release` workflow as soon as the
|
||||
governed tag and unpublished draft exist, in parallel with public asset
|
||||
validation. It must pass the exact public tag/version, set
|
||||
`upload_to_r2=true` and `publish_docker_image=true`, and wait for the exact
|
||||
private image and signed R2 packet to succeed. Only after public release
|
||||
asset validation, staged install smoke, exact public Docker publication,
|
||||
Helm publication, and floating-image promotion succeed may it call the
|
||||
private `rcourtman/pulse-pro` `Promote Paid Runtime Release` workflow with
|
||||
the same version and R2 prefix.
|
||||
The promotion workflow downloads the signed proof packet and runs
|
||||
`scripts/promote_paid_runtime_release_packet.sh --release-dir <proof-packet-dir> --execute-live`
|
||||
from `repos/pulse-pro`. That command is the canonical live-broker promotion
|
||||
@@ -906,9 +915,9 @@ upgrade, update, release, or artifact-selection behavior.
|
||||
manifest on `pulse-license`, runs the customer-path live proof, and restores
|
||||
the previous remote manifest if the gate fails. GA promotions also require
|
||||
`--allow-ga-prefix`. A failed private build or failed live promotion must
|
||||
fail the public release workflow; future private Pro publication must not
|
||||
depend on an operator noticing a manual checklist step after the public RC
|
||||
has shipped.
|
||||
leave the GitHub release unpublished and fail the public release workflow;
|
||||
future private Pro publication must not depend on an operator noticing a
|
||||
manual checklist step after the public RC has shipped.
|
||||
A promotion-only failure must be recoverable by rerunning the public
|
||||
release run's failed jobs: the paid-runtime R2 prefix is derived from
|
||||
run-stable values (the run's creation date and run id, never the
|
||||
@@ -919,6 +928,23 @@ upgrade, update, release, or artifact-selection behavior.
|
||||
A rebuilt packet from identical inputs is waste and lineage churn; a
|
||||
non-empty prefix that fails packet validation must fail the private build
|
||||
instead of being overwritten.
|
||||
GitHub release publication is the final customer notification boundary, not
|
||||
the trigger for downstream artifact work. `activate_release` must verify the
|
||||
draft's tag, target commit, prerelease state, and unpublished state, depend
|
||||
on all required public and private readiness jobs, and only then PATCH
|
||||
`draft=false`. It must prove the public checksums, installer, and canonical
|
||||
Linux archive URLs immediately after activation; a failed public read must
|
||||
attempt to return the release to draft quarantine and fail closed. Exact
|
||||
version artifacts may exist before this boundary so they can be tested, but
|
||||
no GitHub release may advertise a version whose private Pro image or broker
|
||||
manifest is still missing.
|
||||
One immutable-readiness join must cover the staged release packet, staged
|
||||
install smoke, exact public Docker images, both Helm distribution paths, and
|
||||
(for v6) the exact Pro image and signed packet. Only after that join succeeds
|
||||
may the Docker floating aliases and paid-runtime broker manifest advance;
|
||||
those independent mutable pointers should advance in parallel, and final
|
||||
GitHub publication must await both, minimizing the unavoidable cross-system
|
||||
activation interval without pretending the registries share a transaction.
|
||||
A support-only private Pro prerelease image is a narrower exception for
|
||||
customer verification of an already-fixed defect. It may dispatch the private
|
||||
`Build Pro Release` workflow with `publish_docker_image=true`,
|
||||
@@ -1005,7 +1031,8 @@ upgrade, update, release, or artifact-selection behavior.
|
||||
`/usr/local/bin/pulse-agent` symlink exists, points at one of the
|
||||
supported Linux arch binaries, and is executable in the published image.
|
||||
`create-release.yml` must trigger `publish-helm-chart.yml` via an explicit
|
||||
`workflow_call` after `validate_release_assets` succeeds, not rely on
|
||||
`workflow_call` after `validate_release_assets` succeeds and before final
|
||||
activation, not rely on
|
||||
GitHub's `release: published` webhook. The webhook does not fire when a
|
||||
release is created as draft and later PATCHed to `draft=false` (the path
|
||||
`create-release.yml` uses for draft validation), so without the explicit
|
||||
@@ -1020,7 +1047,12 @@ upgrade, update, release, or artifact-selection behavior.
|
||||
detected" no-op as a successful Pages publication for a newly published
|
||||
release version. A successful Pages workflow must create or update the
|
||||
`helm-chart-<version>` release asset and assert that `gh-pages/index.yaml`
|
||||
contains `version: <version>` before the workflow exits green.
|
||||
contains `version: <version>` before the workflow exits green. It must be an
|
||||
awaited `workflow_call` from `create-release.yml`, not an asynchronous
|
||||
`workflow_run` child of Docker publication. It must package from the exact
|
||||
validated release tag without committing generated metadata back onto the
|
||||
governed source branch, then prove the public Pages repository can resolve
|
||||
and download that exact chart version before final release activation.
|
||||
After pushing the OCI chart, `publish-helm-chart.yml` must prove the
|
||||
pushed chart is readable from GHCR without registry credentials by logging
|
||||
out of `ghcr.io` and running `helm show chart` against the versioned chart
|
||||
@@ -1029,15 +1061,15 @@ upgrade, update, release, or artifact-selection behavior.
|
||||
visibility endpoints create false success and noisy release logs, while the
|
||||
unauthenticated chart read is the customer-facing availability contract.
|
||||
`create-release.yml` must apply the same explicit `workflow_call` to
|
||||
`promote-floating-tags.yml`. Its legacy `workflow_run` chain off
|
||||
`publish-docker.yml` silently stops promoting `latest` / major / minor
|
||||
tags whenever `publish-docker.yml` fails (rc.3 → rc.5 all failed at the
|
||||
removed pulse-agent push step), leaving customers on stale floating
|
||||
tags with no warning. `promote-floating-tags.yml` must expose
|
||||
`workflow_call` inputs (`tag`, `prerelease`) and its tag resolver must
|
||||
prefer those over the workflow_run-derived tag, and the create-release
|
||||
wiring must gate on `validate_release_assets` succeeding so the docker
|
||||
image is guaranteed pullable before promotion.
|
||||
`promote-floating-tags.yml`. The legacy `workflow_run` chain off
|
||||
`publish-docker.yml` is forbidden because it creates a second, implicit
|
||||
owner that may move aliases outside the activation sequence. The exact-image
|
||||
publisher must publish only immutable version tags; `promote-floating-tags`
|
||||
is the sole owner of `rc`, `latest`, major, and major/minor aliases for both
|
||||
`pulse` and `pulse-control-plane` on Docker Hub and GHCR. It must expose
|
||||
`workflow_call` inputs (`tag`, `prerelease`), and the create-release wiring
|
||||
must gate on `validate_release_assets` and exact Docker publication so every
|
||||
alias points at a pullable manifest before GitHub release activation.
|
||||
Generated chart docs are part of the packaged release artifact, not a
|
||||
disposable byproduct: when the stable candidate version changes, the checked
|
||||
in `deploy/helm/pulse/README.md` output must be regenerated from the same
|
||||
@@ -1636,10 +1668,11 @@ verification-only mode against the latest stable release. It must prove
|
||||
Tailscale, SSH host identity, runtime version, frontend parity, public health,
|
||||
and browser smoke without changing the host.
|
||||
That same release-validation boundary also owns draft-versus-published asset
|
||||
state. When `.github/workflows/create-release.yml` runs in `draft_only` mode,
|
||||
it must pass the real draft state into `.github/workflows/validate-release-assets.yml`
|
||||
so validation blocks or annotates the draft release as a draft, rather than
|
||||
misclassifying the run as post-publish revalidation.
|
||||
state. Every normal `.github/workflows/create-release.yml` cut validates the
|
||||
uploaded packet while the release is still a draft and must pass `draft=true`
|
||||
into `.github/workflows/validate-release-assets.yml`. Draft-only runs stop at
|
||||
that state; publication runs continue through the readiness barrier. Neither
|
||||
path may misclassify staged validation as post-publication revalidation.
|
||||
That same reusable-validation call boundary also owns permission handoff.
|
||||
`.github/workflows/create-release.yml` must explicitly grant the nested
|
||||
`.github/workflows/validate-release-assets.yml` call the write scopes it
|
||||
@@ -1747,8 +1780,13 @@ short retries before any installer or binary copy runs; a long `ssh-keyscan`
|
||||
loop must not hide an ACL, peer-propagation, firewall, or sshd failure.
|
||||
`create-release.yml` must call the update workflow as an awaited reusable job,
|
||||
and its terminal `Definitive Release Verdict` must require stable demo runtime,
|
||||
frontend, public health, and browser proof. An asynchronous dispatch or manual
|
||||
SSH deployment is not release completion. A one-shot `ssh-keyscan`
|
||||
frontend, public health, and browser proof. During a stable release cut, the
|
||||
update workflow must accept the staged draft release ID, download the exact
|
||||
Linux archive and signature through the authenticated release-assets API, and
|
||||
install that archive without waiting for the public release URL. The stable
|
||||
demo update may run in parallel with the other mutable customer pointers after
|
||||
immutable readiness, and final GitHub publication must await its proof. An
|
||||
asynchronous dispatch or manual SSH deployment is not release completion. A one-shot `ssh-keyscan`
|
||||
against a private demo target is not sufficient release or deploy proof.
|
||||
Those same workflows also own customer-visible browser truth for the public
|
||||
demo shell. Health checks and entry-asset parity are necessary but not
|
||||
@@ -1796,8 +1834,8 @@ messages must describe the path as a prerelease or preview flow rather than
|
||||
implying a near-ready release candidate, while machine-owned identifiers such
|
||||
as `rc`, `rc-to-ga-*`, and `v6.0.0-rc.1` remain the canonical internal keys.
|
||||
That same downstream-dispatch boundary also owns release-ref fidelity. When
|
||||
`.github/workflows/create-release.yml` fans out to governed post-publish
|
||||
workflows such as Docker publication or demo updates, it must dispatch those
|
||||
`.github/workflows/create-release.yml` fans out to governed staging or
|
||||
post-activation workflows such as Docker publication or demo updates, it must dispatch those
|
||||
workflows on `needs.prepare.outputs.required_branch` rather than GitHub's
|
||||
default-branch workflow definition, so prerelease automation cannot silently
|
||||
fall back onto stale `main`-branch inputs or older demo verification logic.
|
||||
@@ -1807,10 +1845,10 @@ Helm release workflows must derive the owning branch from the target version via
|
||||
must check out either that governed release branch or the validated release tag
|
||||
before touching chart contents, and must never hardcode `main` as the push or
|
||||
package source for prerelease Helm publication.
|
||||
Pre-publication release proof and post-publication chart publication have
|
||||
different trust jobs and must stay that way: `.github/workflows/create-release.yml`
|
||||
Pre-activation release proof and versioned chart publication have different
|
||||
trust jobs and must stay that way: `.github/workflows/create-release.yml`
|
||||
must smoke the Helm chart against a locally built release-line image before the
|
||||
tag is published, while `.github/workflows/helm-pages.yml` must continue
|
||||
tag is activated, while `.github/workflows/helm-pages.yml` must continue
|
||||
smoking the immutable published tag image so chart publication cannot silently
|
||||
pass on branch-only fixes that never made it into the released artifact.
|
||||
That same promotion-governance package also owns the dated rehearsal-record
|
||||
|
||||
@@ -355,6 +355,8 @@ func TestCreateReleaseUploadsPowerShellInstaller(t *testing.T) {
|
||||
`needs.validate_release_assets.result == 'success'`,
|
||||
`needs.prepare.outputs.historical_asset_backfill_only != 'true'`,
|
||||
`repository: ${{ github.repository }}`,
|
||||
`asset_source: staged`,
|
||||
`release_id: ${{ needs.create_release.outputs.release_id }}`,
|
||||
// Helm chart publish must be called explicitly from create-release
|
||||
// because the draft→PATCH(draft=false) publish path does NOT fire
|
||||
// the `release: published` webhook (GitHub-documented quirk). v6
|
||||
@@ -365,23 +367,22 @@ func TestCreateReleaseUploadsPowerShellInstaller(t *testing.T) {
|
||||
`publish_helm_chart:`,
|
||||
`chart_version: ${{ needs.prepare.outputs.version }}`,
|
||||
`app_version: ${{ needs.prepare.outputs.version }}`,
|
||||
// promote-floating-tags chains off publish-docker via workflow_run,
|
||||
// but when publish-docker fails (rc.3 → rc.5 all did) the chain
|
||||
// silently doesn't fire and latest/major/minor docker tags stay
|
||||
// stale. Defensive workflow_call backup, gated on
|
||||
// validate_release_assets succeeding (which waits for the image to
|
||||
// be pullable, so the tag points at a real manifest).
|
||||
`uses: ./.github/workflows/helm-pages.yml`,
|
||||
`publish_helm_pages:`,
|
||||
// Mutable image aliases have one explicit owner at the activation
|
||||
// barrier; no implicit workflow_run may race that call.
|
||||
`uses: ./.github/workflows/promote-floating-tags.yml`,
|
||||
`promote_floating_tags:`,
|
||||
`tag: ${{ needs.prepare.outputs.tag }}`,
|
||||
`prerelease: ${{ needs.prepare.outputs.is_prerelease == 'true' }}`,
|
||||
// Draft-only mode (draft_only=true input) keeps the release as a
|
||||
// draft and skips the publish step. The three workflow_call'd
|
||||
// downstreams must skip in that mode too — install-sh-smoke can't
|
||||
// reach the /releases/download/<tag>/ URL of a draft (404), and
|
||||
// helm publish + tag promotion would advance externally-visible
|
||||
// state to a release that hasn't been promoted out of draft yet.
|
||||
// Draft-only mode stops after staged validation and skips the
|
||||
// customer activation sequence.
|
||||
`needs.prepare.outputs.historical_asset_backfill_only != 'true' && github.event.inputs.draft_only != 'true'`,
|
||||
`activate_release:`,
|
||||
`needs.promote_private_pro_runtime.result == 'success'`,
|
||||
`Publish the fully staged release`,
|
||||
`'{draft: false, make_latest: $make_latest}'`,
|
||||
`returning ${TAG} to draft quarantine`,
|
||||
}
|
||||
for _, needle := range required {
|
||||
if !strings.Contains(workflow, needle) {
|
||||
@@ -390,15 +391,27 @@ func TestCreateReleaseUploadsPowerShellInstaller(t *testing.T) {
|
||||
}
|
||||
|
||||
publishedReleaseGuard := `needs.prepare.outputs.historical_asset_backfill_only != 'true' && github.event.inputs.draft_only != 'true'`
|
||||
for _, job := range []string{"install_sh_smoke", "publish_helm_chart", "promote_floating_tags"} {
|
||||
for _, job := range []string{"install_sh_smoke", "publish_helm_chart", "publish_helm_pages"} {
|
||||
block := workflowJobBlock(t, workflow, job)
|
||||
if !strings.Contains(block, publishedReleaseGuard) {
|
||||
t.Fatalf("create-release.yml job %s must skip historical backfill and draft-only runs before invoking downstream workflow_call", job)
|
||||
}
|
||||
}
|
||||
readinessJob := workflowJobBlock(t, workflow, "release_readiness")
|
||||
if !strings.Contains(readinessJob, publishedReleaseGuard) {
|
||||
t.Fatal("release_readiness must skip historical backfill and draft-only runs")
|
||||
}
|
||||
floatingJob := workflowJobBlock(t, workflow, "promote_floating_tags")
|
||||
if !strings.Contains(floatingJob, `needs.release_readiness.result == 'success'`) {
|
||||
t.Fatal("floating-tag promotion must run only after the immutable readiness barrier")
|
||||
}
|
||||
|
||||
if !strings.Contains(workflow, `draft: ${{ github.event.inputs.draft_only == 'true' }}`) {
|
||||
t.Fatal("create-release.yml must pass the actual draft_only state into validate-release-assets")
|
||||
if !strings.Contains(workflow, `draft: true`) {
|
||||
t.Fatal("create-release.yml must validate the release while it remains staged as a draft")
|
||||
}
|
||||
createJob := workflowJobBlock(t, workflow, "create_release")
|
||||
if strings.Contains(createJob, `draft=false`) || strings.Contains(createJob, `Publish release`) {
|
||||
t.Fatal("create_release must stage assets without crossing the customer publication boundary")
|
||||
}
|
||||
if strings.Contains(workflow, `provenance: false`) {
|
||||
t.Fatal("create-release.yml must not disable release-image provenance")
|
||||
@@ -1228,6 +1241,8 @@ func TestDeploymentDefaultsPinVersionedImagesAndHelmDocsChecksum(t *testing.T) {
|
||||
}
|
||||
helmPages := string(helmPagesBytes)
|
||||
required := []string{
|
||||
`workflow_call:`,
|
||||
`chart_version:`,
|
||||
`HELM_DOCS_VERSION="1.14.2"`,
|
||||
`HELM_DOCS_ARCHIVE="helm-docs_${HELM_DOCS_VERSION}_Linux_x86_64.tar.gz"`,
|
||||
`HELM_DOCS_SHA256="a8cf72ada34fad93285ba2a452b38bdc5bd52cc9a571236244ec31022928d6cc"`,
|
||||
@@ -1237,12 +1252,22 @@ func TestDeploymentDefaultsPinVersionedImagesAndHelmDocsChecksum(t *testing.T) {
|
||||
`helm repo index "${index_work}"`,
|
||||
`git -C "${workdir}/gh-pages" push origin HEAD:gh-pages`,
|
||||
`grep -q "version: ${VERSION}"`,
|
||||
`helm show chart pulse-public/pulse --version "$VERSION"`,
|
||||
}
|
||||
for _, needle := range required {
|
||||
if !strings.Contains(helmPages, needle) {
|
||||
t.Fatalf("helm-pages.yml missing checksum-verified helm-docs install step: %s", needle)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{
|
||||
"workflow_run:",
|
||||
`git checkout -B "$REQUIRED_BRANCH"`,
|
||||
`git push origin HEAD:"$REQUIRED_BRANCH"`,
|
||||
} {
|
||||
if strings.Contains(helmPages, forbidden) {
|
||||
t.Fatalf("helm-pages.yml must be an awaited exact-tag staging job; found forbidden %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelmChartDoesNotPublishRetiredExplorePrepassMonitoring(t *testing.T) {
|
||||
@@ -1343,6 +1368,10 @@ func TestUpdateDemoWorkflowUsesGovernedNetworkPath(t *testing.T) {
|
||||
`bash .github/scripts/check-demo-reachability.sh`,
|
||||
`workflow_call:`,
|
||||
`verify_only:`,
|
||||
`release_id:`,
|
||||
`repos/${{ github.repository }}/releases/${RELEASE_ID}/assets?per_page=100`,
|
||||
`Accept: application/octet-stream`,
|
||||
`--archive "/tmp/${tarball}" --disable-auto-updates`,
|
||||
`Refuse mutation during verification-only checks`,
|
||||
`uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0`,
|
||||
`go run ./scripts/release_update_key.go public-key-ssh`,
|
||||
@@ -1765,22 +1794,25 @@ func TestBuildReleasePackagesPulseMcpForAllPlatforms(t *testing.T) {
|
||||
}
|
||||
|
||||
// The release-pipeline downstream workflows and private Pro publication path
|
||||
// share the same root cause: v6 rc.1 -> rc.6 silently broke because GitHub's
|
||||
// `release: published` webhook doesn't fire when create-release.yml's draft ->
|
||||
// PATCH(draft=false) promotion path is used, `workflow_run` chains don't fire
|
||||
// when their upstream fails, and the private Pro path was left as a manual
|
||||
// checklist step. The fix is explicit post-release orchestration after
|
||||
// validate_release_assets succeeds. The tests below pin the trigger
|
||||
// declarations, resolver logic, and private Pro dispatch contract so the
|
||||
// regression class can't return.
|
||||
// share one customer boundary. Exact-version artifacts are staged behind a
|
||||
// draft, verified, and only then activated; GitHub publication is the final
|
||||
// notification rather than the trigger for a long tail of publication work.
|
||||
// The tests below pin that barrier so the staggered-release regression class
|
||||
// cannot return.
|
||||
|
||||
func TestInstallShSmokeWorkflowPresent(t *testing.T) {
|
||||
assertFileContainsAll(t, repoFile(".github", "workflows", "install-sh-smoke.yml"),
|
||||
// Inputs and triggers.
|
||||
`name: install.sh Smoke (Published Release)`,
|
||||
`name: install.sh Smoke (Release Assets)`,
|
||||
`workflow_call:`,
|
||||
`workflow_dispatch:`,
|
||||
// Pull straight from the published release URL (not local release/).
|
||||
`asset_source:`,
|
||||
`release_id:`,
|
||||
// Staged cuts use authenticated draft assets; manual verification can
|
||||
// still pull from the public release URL.
|
||||
`repos/${REPO}/releases/${RELEASE_ID}/assets?per_page=100`,
|
||||
`repos/${REPO}/releases/assets/${asset_id}`,
|
||||
`Accept: application/octet-stream`,
|
||||
`releases/download/${TAG}`,
|
||||
`install.sh.sshsig`,
|
||||
`pulse-${TAG}-linux-amd64.tar.gz`,
|
||||
@@ -1807,18 +1839,40 @@ func TestInstallShSmokeWorkflowPresent(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPromoteFloatingTagsReachableViaWorkflowCall(t *testing.T) {
|
||||
assertFileContainsAll(t, repoFile(".github", "workflows", "promote-floating-tags.yml"),
|
||||
workflowPath := repoFile(".github", "workflows", "promote-floating-tags.yml")
|
||||
assertFileContainsAll(t, workflowPath,
|
||||
`workflow_call:`,
|
||||
`tag:`,
|
||||
`description: "Release tag (e.g., v6.0.0). Required for workflow_call."`,
|
||||
`prerelease:`,
|
||||
`type: boolean`,
|
||||
// Job condition must accept workflow_call alongside workflow_dispatch.
|
||||
`github.event_name == 'workflow_call'`,
|
||||
// Tag resolver must prefer inputs over the workflow_run derivation.
|
||||
`if [ -n "${INPUT_TAG}" ]; then`,
|
||||
`TAG="${INPUT_TAG}"`,
|
||||
`for image in pulse pulse-control-plane; do`,
|
||||
`"rcourtman/${image}:rc"`,
|
||||
`"ghcr.io/${OWNER}/${image}:latest"`,
|
||||
)
|
||||
content, err := os.ReadFile(workflowPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read promote-floating-tags.yml: %v", err)
|
||||
}
|
||||
if strings.Contains(string(content), "workflow_run:") {
|
||||
t.Fatal("floating aliases must have one explicit activation owner, not an implicit workflow_run trigger")
|
||||
}
|
||||
publishBytes, err := os.ReadFile(repoFile(".github", "workflows", "publish-docker.yml"))
|
||||
if err != nil {
|
||||
t.Fatalf("read publish-docker.yml: %v", err)
|
||||
}
|
||||
publishWorkflow := string(publishBytes)
|
||||
for _, mutableTag := range []string{
|
||||
`rcourtman/pulse:latest`,
|
||||
`ghcr.io/{0}/pulse:latest`,
|
||||
`rcourtman/pulse-control-plane:latest`,
|
||||
`ghcr.io/{0}/pulse-control-plane:latest`,
|
||||
} {
|
||||
if strings.Contains(publishWorkflow, mutableTag) {
|
||||
t.Fatalf("publish-docker.yml must stage exact-version images without moving mutable alias %q", mutableTag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishHelmChartReachableViaWorkflowCall(t *testing.T) {
|
||||
@@ -1877,7 +1931,10 @@ func TestReleasePipelinePromotesOneImmutableCandidate(t *testing.T) {
|
||||
backendJob := workflowJobBlock(t, createWorkflow, "backend_tests")
|
||||
integrationJob := workflowJobBlock(t, createWorkflow, "integration_tests")
|
||||
validationJob := workflowJobBlock(t, createWorkflow, "validate_release_assets")
|
||||
privateJob := workflowJobBlock(t, createWorkflow, "publish_private_pro_runtime")
|
||||
privateStageJob := workflowJobBlock(t, createWorkflow, "stage_private_pro_runtime")
|
||||
readinessJob := workflowJobBlock(t, createWorkflow, "release_readiness")
|
||||
privatePromotionJob := workflowJobBlock(t, createWorkflow, "promote_private_pro_runtime")
|
||||
activationJob := workflowJobBlock(t, createWorkflow, "activate_release")
|
||||
|
||||
for _, needle := range []string{
|
||||
`./scripts/build-release.sh "${{ inputs.version }}"`,
|
||||
@@ -1919,8 +1976,39 @@ func TestReleasePipelinePromotesOneImmutableCandidate(t *testing.T) {
|
||||
if strings.Contains(validationJob, "- publish_docker") {
|
||||
t.Fatal("release asset digest validation must run in parallel with Docker publication")
|
||||
}
|
||||
if !strings.Contains(privateJob, "- create_release") || strings.Contains(privateJob, "- validate_release_assets") {
|
||||
t.Fatal("private Pro publication must start after release creation without waiting for asset validation")
|
||||
if !strings.Contains(privateStageJob, "- create_release") || strings.Contains(privateStageJob, "- validate_release_assets") {
|
||||
t.Fatal("private Pro staging must start after draft creation without waiting for asset validation")
|
||||
}
|
||||
for _, dependency := range []string{
|
||||
"- create_release",
|
||||
"- publish_docker",
|
||||
"- validate_release_assets",
|
||||
"- install_sh_smoke",
|
||||
"- publish_helm_chart",
|
||||
"- publish_helm_pages",
|
||||
"- stage_private_pro_runtime",
|
||||
} {
|
||||
if !strings.Contains(readinessJob, dependency) {
|
||||
t.Fatalf("immutable release readiness missing dependency: %s", dependency)
|
||||
}
|
||||
}
|
||||
for _, dependency := range []string{"- release_readiness", "- stage_private_pro_runtime"} {
|
||||
if !strings.Contains(privatePromotionJob, dependency) {
|
||||
t.Fatalf("private Pro live promotion missing staging dependency: %s", dependency)
|
||||
}
|
||||
}
|
||||
for _, dependency := range []string{
|
||||
"- release_readiness",
|
||||
"- update_stable_demo",
|
||||
"- promote_floating_tags",
|
||||
"- promote_private_pro_runtime",
|
||||
} {
|
||||
if !strings.Contains(activationJob, dependency) {
|
||||
t.Fatalf("release activation missing readiness dependency: %s", dependency)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(activationJob, `'{draft: false, make_latest: $make_latest}'`) {
|
||||
t.Fatal("release activation must be the job that crosses the draft publication boundary")
|
||||
}
|
||||
for _, needle := range []string{
|
||||
`inputs.candidate_manifest_artifact != ''`,
|
||||
@@ -1985,7 +2073,8 @@ func TestCreateReleasePublishesPrivateProRuntime(t *testing.T) {
|
||||
t.Fatalf("read create-release.yml: %v", err)
|
||||
}
|
||||
workflow := string(content)
|
||||
job := workflowJobBlock(t, workflow, "publish_private_pro_runtime")
|
||||
stageJob := workflowJobBlock(t, workflow, "stage_private_pro_runtime")
|
||||
promotionJob := workflowJobBlock(t, workflow, "promote_private_pro_runtime")
|
||||
|
||||
for _, needle := range []string{
|
||||
`needs.create_release.result == 'success'`,
|
||||
@@ -2006,19 +2095,28 @@ func TestCreateReleasePublishesPrivateProRuntime(t *testing.T) {
|
||||
`-f reuse_existing_packet=true`,
|
||||
`-f allow_stable_ga_publish="${allow_ga_publish}"`,
|
||||
`wait_for_workflow rcourtman/pulse-enterprise "Build Pro Release" main "${build_started_at}" "private Pro build"`,
|
||||
`echo "r2_prefix=${r2_prefix}" >> "$GITHUB_OUTPUT"`,
|
||||
} {
|
||||
if !strings.Contains(stageJob, needle) {
|
||||
t.Fatalf("stage_private_pro_runtime missing required contract: %s", needle)
|
||||
}
|
||||
}
|
||||
for _, needle := range []string{
|
||||
`needs.stage_private_pro_runtime.result == 'success'`,
|
||||
`R2_PREFIX: ${{ needs.stage_private_pro_runtime.outputs.r2_prefix }}`,
|
||||
`gh workflow run promote-paid-runtime-release.yml`,
|
||||
`--repo rcourtman/pulse-pro`,
|
||||
`-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"`,
|
||||
`echo "::error::${label} failed with conclusion=${conclusion}: ${url}"`,
|
||||
} {
|
||||
if !strings.Contains(job, needle) {
|
||||
t.Fatalf("publish_private_pro_runtime missing required contract: %s", needle)
|
||||
if !strings.Contains(promotionJob, needle) {
|
||||
t.Fatalf("promote_private_pro_runtime missing required contract: %s", needle)
|
||||
}
|
||||
}
|
||||
if strings.Contains(job, "continue-on-error: true") {
|
||||
t.Fatal("publish_private_pro_runtime must fail the release pipeline when private Pro publication or promotion fails")
|
||||
if strings.Contains(stageJob, "continue-on-error: true") || strings.Contains(promotionJob, "continue-on-error: true") {
|
||||
t.Fatal("private Pro staging and promotion must fail the release pipeline on error")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -718,7 +718,9 @@ class ReleasePromotionPolicyTest(unittest.TestCase):
|
||||
self.assertIn("Planned v5 end-of-support date", renderer)
|
||||
self.assertIn("historical_asset_backfill_only:", content)
|
||||
self.assertIn("Repair an already-published release packet in place without rebuilding binaries", content)
|
||||
self.assertIn("draft: ${{ github.event.inputs.draft_only == 'true' }}", content)
|
||||
self.assertIn("draft: true", content)
|
||||
self.assertIn("activate_release:", content)
|
||||
self.assertIn("Publish the fully staged release", content)
|
||||
self.assertIn('gh api "repos/${{ github.repository }}/releases?per_page=100" --paginate', content)
|
||||
self.assertIn('git push origin "refs/tags/${TAG}" --force', content)
|
||||
self.assertIn('Retargeting existing draft tag ${TAG}', content)
|
||||
@@ -1090,9 +1092,11 @@ class ReleasePromotionPolicyTest(unittest.TestCase):
|
||||
self.assertIn('--set image.pullPolicy=Never', release_workflow)
|
||||
self.assertIn("needs.helm_smoke.result == 'success'", release_workflow)
|
||||
self.assertIn('--github-output "$GITHUB_OUTPUT"', helm_pages)
|
||||
self.assertIn('git checkout -B "$REQUIRED_BRANCH" "origin/$REQUIRED_BRANCH"', helm_pages)
|
||||
self.assertIn('git pull --rebase origin "$REQUIRED_BRANCH"', helm_pages)
|
||||
self.assertIn('git push origin HEAD:"$REQUIRED_BRANCH"', helm_pages)
|
||||
self.assertIn("workflow_call:", helm_pages)
|
||||
self.assertNotIn("workflow_run:", helm_pages)
|
||||
self.assertIn('git checkout --detach "refs/tags/${RELEASE_TAG}"', helm_pages)
|
||||
self.assertNotIn('git pull --rebase origin "$REQUIRED_BRANCH"', helm_pages)
|
||||
self.assertNotIn('git push origin HEAD:"$REQUIRED_BRANCH"', helm_pages)
|
||||
self.assertIn('HELM_DOCS_VERSION="1.14.2"', helm_pages)
|
||||
self.assertIn('HELM_DOCS_ARCHIVE="helm-docs_${HELM_DOCS_VERSION}_Linux_x86_64.tar.gz"', helm_pages)
|
||||
self.assertIn(
|
||||
@@ -1108,6 +1112,7 @@ class ReleasePromotionPolicyTest(unittest.TestCase):
|
||||
self.assertIn('helm repo index "${index_work}"', helm_pages)
|
||||
self.assertIn('git -C "${workdir}/gh-pages" push origin HEAD:gh-pages', helm_pages)
|
||||
self.assertIn('grep -q "version: ${VERSION}"', helm_pages)
|
||||
self.assertIn('helm show chart pulse-public/pulse --version "$VERSION"', helm_pages)
|
||||
self.assertIn("helm status pulse || true", helm_pages)
|
||||
self.assertIn("kubectl describe pods -A || true", helm_pages)
|
||||
self.assertIn("kubectl get events -A --sort-by=.lastTimestamp || kubectl get events -A || true", helm_pages)
|
||||
|
||||
Reference in New Issue
Block a user