From c04ac2f7cfc5dff0a43ba003fb0997cbe70dae6f Mon Sep 17 00:00:00 2001 From: rcourtman Date: Mon, 15 Jun 2026 15:37:13 +0100 Subject: [PATCH] Make private Pro publication block v6 releases --- .github/workflows/create-release.yml | 117 ++++++++++++++++++ .../v6/internal/RELEASE_PROMOTION_POLICY.md | 16 ++- ...utomatic-private-pro-release-2026-06-15.md | 77 ++++++++++++ docs/release-control/v6/internal/status.json | 8 +- .../subsystems/deployment-installability.md | 24 ++-- .../installtests/build_release_assets_test.go | 58 +++++++-- 6 files changed, 281 insertions(+), 19 deletions(-) create mode 100644 docs/release-control/v6/internal/records/paid-runtime-build-attribution-alerting-automatic-private-pro-release-2026-06-15.md diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index f9bad6c6b..2678dc2e4 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -1151,3 +1151,120 @@ jobs: with: 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. + publish_private_pro_runtime: + 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' && startsWith(needs.prepare.outputs.version, '6.') }} + runs-on: ubuntu-24.04 + timeout-minutes: 150 + steps: + - name: Dispatch and verify private Pro runtime publication + env: + GH_TOKEN: ${{ secrets.WORKFLOW_PAT }} + VERSION: ${{ needs.prepare.outputs.version }} + TAG: ${{ needs.prepare.outputs.tag }} + IS_PRERELEASE: ${{ needs.prepare.outputs.is_prerelease }} + run: | + set -euo pipefail + + if [[ -z "${GH_TOKEN:-}" ]]; then + echo "::error::WORKFLOW_PAT is required to dispatch private Pro publication workflows." + exit 1 + fi + + wait_for_workflow() { + local repo="$1" + local 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 + + r2_prefix="${TAG}-pro-$(date -u '+%Y%m%d')-${GITHUB_RUN_ID}" + 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 \ + --repo rcourtman/pulse-enterprise \ + --ref main \ + -f pulse_ref="${TAG}" \ + -f version="${VERSION}" \ + -f upload_actions_artifact=false \ + -f upload_to_r2=true \ + -f publish_docker_image=true \ + -f docker_image=license.pulserelay.pro/pulse-pro \ + -f r2_prefix="${r2_prefix}" \ + -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 + + promote_started_at="$(date -u '+%Y-%m-%dT%H:%M:%SZ')" + 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 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 diff --git a/docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md b/docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md index d0d4edace..1513c2f30 100644 --- a/docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md +++ b/docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md @@ -70,11 +70,23 @@ Cloud, and self-hosted production users. 4. A private Pro build from a moving branch is valid only as an internal proof artifact. It is not valid customer guidance and must not update the live paid-download manifest or private Docker customer tag. -5. Customer-facing private Pro archive or Docker promotion must use the generated +5. Customer-facing private Pro archive and Docker publication is part of the + public v6 release pipeline. After `validate-release-assets.yml` succeeds for + a non-draft v6 release, `create-release.yml` must dispatch + `rcourtman/pulse-enterprise` `Build Pro Release` against the exact public + tag with `upload_to_r2=true`, `publish_docker_image=true`, and an R2 prefix + derived by the release run, then wait for that workflow to succeed. +6. The public v6 release pipeline must then dispatch `rcourtman/pulse-pro` + `Promote Paid Runtime Release` with the same version and R2 prefix, and + wait for the signed packet to promote the live paid-download broker. A failed + private build or failed live promotion fails the public release workflow; + private Pro RC/GA advancement must not depend on an operator noticing a + checklist item after the public RC has shipped. +7. Customer-facing private Pro archive or Docker promotion must use the generated paid-runtime proof packet from the Pro release workflow. The canonical command is `scripts/promote_paid_runtime_release_packet.sh --release-dir --admin-token-file --execute-live` from `repos/pulse-pro`; GA promotions also require `--allow-ga-prefix`. -6. The promotion command is the release gate for the live paid-download broker: +8. The promotion command is the release gate for the live paid-download broker: it validates the proof packet signatures, installs the exact manifest on `pulse-license`, runs the live customer-path proof, and restores the previous remote manifest if the gate fails. Do not send customer instructions from a diff --git a/docs/release-control/v6/internal/records/paid-runtime-build-attribution-alerting-automatic-private-pro-release-2026-06-15.md b/docs/release-control/v6/internal/records/paid-runtime-build-attribution-alerting-automatic-private-pro-release-2026-06-15.md new file mode 100644 index 000000000..68d241eb1 --- /dev/null +++ b/docs/release-control/v6/internal/records/paid-runtime-build-attribution-alerting-automatic-private-pro-release-2026-06-15.md @@ -0,0 +1,77 @@ +# Paid Runtime Automatic Private Pro Release Gate + +Date: 2026-06-15 +Owner: paid-runtime-build-attribution-alerting +Evidence tier: test-proof + +## Trigger + +A paid customer reported that the private Pulse Pro v6 download links stopped at +`6.0.0-rc.4` even though public v6 RCs had advanced past RC4. + +## Finding + +The live `pulse-license` private Pro release manifest still pointed at +`6.0.0-rc.4` with prefix `v6.0.0-rc.4-pro-20260507`. Public releases +`v6.0.0-rc.5` and `v6.0.0-rc.6` existed, but `rcourtman/pulse-enterprise` +had no later `Build Pro Release` workflow-dispatch run after the corrected RC4 +customer-facing Pro publish on 2026-05-07. + +The previous policy and checklist required a generated proof packet plus +`scripts/promote_paid_runtime_release_packet.sh`, but that path was still a +manual post-release operation. Public RC publication could therefore advance +without automatically building or promoting the matching private Pro runtime. + +## Decision + +For every non-draft v6 public release, the public release workflow owns the +private Pro runtime publication handoff: + +1. After public asset validation succeeds, dispatch `rcourtman/pulse-enterprise` + `Build Pro Release` against the exact public tag and version. +2. Require `upload_actions_artifact=false`, `upload_to_r2=true`, and + `publish_docker_image=true`. +3. Derive the private R2 prefix from the public release workflow run. +4. Wait for the private Pro R2/Docker publication workflow to succeed. +5. Dispatch `rcourtman/pulse-pro` `Promote Paid Runtime Release` with the same + version and R2 prefix. +6. Wait for the live paid-download broker promotion to succeed. + +A failed private build or failed live promotion fails the public release +workflow. Private Pro RC/GA advancement must not depend on an operator noticing +a checklist item after the public RC has shipped. + +## Implementation + +- `repos/pulse/.github/workflows/create-release.yml` now has a + `publish_private_pro_runtime` job gated on non-draft v6 releases after + `validate_release_assets`. +- The job dispatches `rcourtman/pulse-enterprise` `Build Pro Release`, waits for + completion, dispatches `rcourtman/pulse-pro` `Promote Paid Runtime Release`, + and waits for completion without `continue-on-error`. +- `repos/pulse-pro/.github/workflows/promote-paid-runtime-release.yml` downloads + the signed R2 proof packet, verifies its version, then runs + `scripts/promote_paid_runtime_release_packet.sh --release-dir --execute-live`. +- Release policy, deployment-installability ownership docs, Pro operations docs, + the Pro upgrade runbook, and the Pro launch checklist now describe the + automatic path. +- The paid-runtime distribution validator now requires the promotion workflow + and rejects non-blocking promotion drift. +- The legacy license email now repeats the private Pulse Pro runtime handoff for + v6 paid features, including the Linux/Proxmox LXC archive guard. + +## Proof + +- `go test ./scripts/installtests -run 'TestCreateReleasePublishesPrivateProRuntime|TestInstallShSmokeWorkflowPresent|TestPublishHelmChartReachableViaWorkflowCall' -count=1` +- `python3 scripts/validate_paid_runtime_distribution.py` +- `python3 -m unittest scripts.tests.test_validate_paid_runtime_distribution` +- `go test . -run 'Test.*LicenseEmail|TestV6LicenseEmailIncludesPrivateDownloadPage' -count=1` +- YAML parse checks for `.github/workflows/create-release.yml` and + `.github/workflows/promote-paid-runtime-release.yml` +- `git diff --check` in `repos/pulse` and `repos/pulse-pro` + +## Residual + +This record fixes the future release process and prevents another silent private +Pro runtime lag. It does not itself publish a new private Pro artifact for the +already-shipped public RC6 line; that is a separate live release operation. diff --git a/docs/release-control/v6/internal/status.json b/docs/release-control/v6/internal/status.json index cf7d5e99d..42b4f410c 100644 --- a/docs/release-control/v6/internal/status.json +++ b/docs/release-control/v6/internal/status.json @@ -6501,7 +6501,7 @@ }, { "id": "paid-runtime-build-attribution-alerting", - "summary": "Confirm active paid licenses cannot silently run on the public community runtime: Pulse must detect non-Pro or unknown runtime identity for paid installs, surface a clear private-runtime warning in-product, preserve runtime-build telemetry for support, and expose Pro/community/unknown runtime status in admin support views.", + "summary": "Confirm active paid licenses cannot silently run on the public community runtime or on a stale private Pro release line: Pulse must detect non-Pro or unknown runtime identity for paid installs, surface a clear private-runtime warning in-product, preserve runtime-build telemetry for support, expose Pro/community/unknown runtime status in admin support views, and block future v6 public release success when matching private Pro R2/Docker publication or live broker promotion fails.", "owner": "project-owner", "blocking_level": "release-ready", "minimum_evidence_tier": "managed-runtime-exercise", @@ -6520,6 +6520,12 @@ "path": "docs/release-control/v6/internal/records/paid-runtime-build-attribution-alerting-2026-05-07.md", "kind": "file", "evidence_tier": "managed-runtime-exercise" + }, + { + "repo": "pulse", + "path": "docs/release-control/v6/internal/records/paid-runtime-build-attribution-alerting-automatic-private-pro-release-2026-06-15.md", + "kind": "file", + "evidence_tier": "test-proof" } ] }, diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index bcb3362da..f161956df 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -534,14 +534,22 @@ TLS floor in the dynamic config. can keep the normal service setup while installing the private Pulse Pro runtime. Customer-facing private Pro RC/GA promotion is part of that same boundary: - after the `pulse-enterprise` Pro release workflow publishes private archives, - the private Docker image, and the paid-runtime proof packet, the operator must - run `scripts/promote_paid_runtime_release_packet.sh --release-dir --admin-token-file --execute-live` - from `repos/pulse-pro` before sending customer instructions. That command is - the canonical live-broker promotion path because it validates the signed proof - packet, installs the exact 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`. + 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. + 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 + path because it validates the signed proof packet, installs the exact + 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. The repo-root VERSION file is part of the same governed boundary and must not drift as an unowned release-cut switch: changing the version string for a new RC or diff --git a/scripts/installtests/build_release_assets_test.go b/scripts/installtests/build_release_assets_test.go index ecf2f2577..bb25c1b01 100644 --- a/scripts/installtests/build_release_assets_test.go +++ b/scripts/installtests/build_release_assets_test.go @@ -1002,14 +1002,15 @@ func TestBuildReleasePackagesPulseMcpForAllPlatforms(t *testing.T) { } } -// The three release-pipeline downstream workflows (install-sh-smoke, -// promote-floating-tags, publish-helm-chart) all share the same root cause: -// v6 rc.1 → rc.5 silently broke because GitHub's `release: published` webhook -// doesn't fire when create-release.yml's draft → PATCH(draft=false) promotion -// path is used, and `workflow_run` chains don't fire when their upstream -// fails. The fix in each case is a workflow_call entry from create-release.yml -// after validate_release_assets succeeds. The tests below pin the trigger -// declarations and resolver logic so the regression class can't return. +// 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. func TestInstallShSmokeWorkflowPresent(t *testing.T) { assertFileContainsAll(t, repoFile(".github", "workflows", "install-sh-smoke.yml"), @@ -1072,6 +1073,47 @@ func TestPublishHelmChartReachableViaWorkflowCall(t *testing.T) { ) } +func TestCreateReleasePublishesPrivateProRuntime(t *testing.T) { + content, err := os.ReadFile(repoFile(".github", "workflows", "create-release.yml")) + if err != nil { + t.Fatalf("read create-release.yml: %v", err) + } + workflow := string(content) + job := workflowJobBlock(t, workflow, "publish_private_pro_runtime") + + for _, needle := range []string{ + `needs.validate_release_assets.result == 'success'`, + `github.event.inputs.draft_only != 'true'`, + `startsWith(needs.prepare.outputs.version, '6.')`, + `GH_TOKEN: ${{ secrets.WORKFLOW_PAT }}`, + `r2_prefix="${TAG}-pro-$(date -u '+%Y%m%d')-${GITHUB_RUN_ID}"`, + `gh workflow run build-pro-release.yml`, + `--repo rcourtman/pulse-enterprise`, + `-f pulse_ref="${TAG}"`, + `-f version="${VERSION}"`, + `-f upload_actions_artifact=false`, + `-f upload_to_r2=true`, + `-f publish_docker_image=true`, + `-f docker_image=license.pulserelay.pro/pulse-pro`, + `-f r2_prefix="${r2_prefix}"`, + `-f allow_stable_ga_publish="${allow_ga_publish}"`, + `wait_for_workflow rcourtman/pulse-enterprise "Build Pro Release" main "${build_started_at}" "private Pro build"`, + `gh workflow run promote-paid-runtime-release.yml`, + `--repo rcourtman/pulse-pro`, + `-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(job, "continue-on-error: true") { + t.Fatal("publish_private_pro_runtime must fail the release pipeline when private Pro publication or promotion fails") + } +} + func TestHelmAgentRuntimePointsAtRealImage(t *testing.T) { // The helm chart's agent.enabled=true workload used to default to // ghcr.io/rcourtman/pulse-agent — an image that was never published.