diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 4ca5a5ba9..9d4f0e13c 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -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// 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 diff --git a/.github/workflows/helm-pages.yml b/.github/workflows/helm-pages.yml index 48add590b..18e79f761 100644 --- a/.github/workflows/helm-pages.yml +++ b/.github/workflows/helm-pages.yml @@ -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 }} diff --git a/.github/workflows/install-sh-smoke.yml b/.github/workflows/install-sh-smoke.yml index 79f74c74d..fb215f050 100644 --- a/.github/workflows/install-sh-smoke.yml +++ b/.github/workflows/install-sh-smoke.yml @@ -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 diff --git a/.github/workflows/promote-floating-tags.yml b/.github/workflows/promote-floating-tags.yml index e28e5a45b..080120f1c 100644 --- a/.github/workflows/promote-floating-tags.yml +++ b/.github/workflows/promote-floating-tags.yml @@ -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//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." diff --git a/.github/workflows/publish-docker.yml b/.github/workflows/publish-docker.yml index 1eb41c9d1..4a8924a07 100644 --- a/.github/workflows/publish-docker.yml +++ b/.github/workflows/publish-docker.yml @@ -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." diff --git a/.github/workflows/update-demo-server.yml b/.github/workflows/update-demo-server.yml index 5b463eb47..687cbbcbe 100644 --- a/.github/workflows/update-demo-server.yml +++ b/.github/workflows/update-demo-server.yml @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 17bd00001..e49458fd5 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -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 --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-` release asset and assert that `gh-pages/index.yaml` - contains `version: ` before the workflow exits green. + contains `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 diff --git a/scripts/installtests/build_release_assets_test.go b/scripts/installtests/build_release_assets_test.go index 52befec03..8184beb65 100644 --- a/scripts/installtests/build_release_assets_test.go +++ b/scripts/installtests/build_release_assets_test.go @@ -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// 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") } } diff --git a/scripts/release_control/release_promotion_policy_test.py b/scripts/release_control/release_promotion_policy_test.py index 2e17b72e1..f2b7de954 100644 --- a/scripts/release_control/release_promotion_policy_test.py +++ b/scripts/release_control/release_promotion_policy_test.py @@ -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)