Make private Pro publication block v6 releases

This commit is contained in:
rcourtman
2026-06-15 15:37:13 +01:00
parent 7235dcf504
commit c04ac2f7cf
6 changed files with 281 additions and 19 deletions
+117
View File
@@ -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
@@ -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 <proof-packet-dir> --admin-token-file <explicit-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
@@ -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 <proof-packet-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.
+7 -1
View File
@@ -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"
}
]
},
@@ -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 <proof-packet-dir> --admin-token-file <explicit-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 <proof-packet-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
@@ -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.