fix(release): stage artifacts before publication

This commit is contained in:
Richard Courtman
2026-08-07 12:27:36 +01:00
parent d699dc66a0
commit 5e9db16927
9 changed files with 753 additions and 350 deletions
+269 -76
View File
@@ -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
+37 -53
View File
@@ -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 }}
+90 -21
View File
@@ -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
+59 -95
View File
@@ -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."
+3 -17
View File
@@ -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."
+68 -2
View File
@@ -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
+137 -39
View File
@@ -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)