Fix RC4 release validation blockers

This commit is contained in:
rcourtman 2026-05-05 15:59:23 +01:00
parent f149c5d643
commit 96c2e160c9
12 changed files with 118 additions and 65 deletions

View file

@ -2,7 +2,7 @@ version: '3.8'
services:
pulse:
image: ${PULSE_IMAGE:-rcourtman/pulse:6.0.0-rc.3}
image: ${PULSE_IMAGE:-rcourtman/pulse:6.0.0-rc.4}
container_name: pulse
restart: unless-stopped
logging:

View file

@ -93,13 +93,36 @@ The current RC packet now points at `v6.0.0-rc.4`:
- `docs/release-control/v6/internal/subsystems/deployment-installability.md`
- records that RC4 follows the later-corrective-RC packet requirements for
rollback, trust-root continuity, and release-control evidence
- `docker-compose.yml`
- pins the default Pulse image tag to `6.0.0-rc.4`
- `scripts/install-docker.sh`
- pins the standalone installer fallback image version to `6.0.0-rc.4`
- `internal/config/billing_state_test.go`
- asserts that billing-state normalization scrubs retired monitored-system
limit metadata instead of preserving it as entitlement state
- `tests/migration/v5_full_upgrade_test.go` and
`tests/migration/v5_real_exchange_upgrade_test.go`
- align v5-to-v6 exchange proof with the canonical self-hosted contract:
migrated self-hosted plans preserve plan identity and paid continuity
without reintroducing monitored-system caps
## Outcome
The audit did not identify a new unhandled code blocker from the commit range.
It did identify a release-packet currentness gap because the repo still pointed
operators at `rc.3` while the branch had moved beyond the published `rc.3`
tag. The gap is addressed by the RC4 packet before release workflow dispatch.
The audit did not identify a new unhandled code blocker from the feature/runtime
commit range. It did identify a release-packet currentness gap because the repo
still pointed operators at `rc.3` while the branch had moved beyond the
published `rc.3` tag. The initial draft release workflow also exposed two
release-validation gaps before publication:
- Docker Compose and turnkey Docker install defaults still pinned the RC3 image
tag.
- Backend migration and billing-state tests still expected the retired
`max_monitored_systems` contract to survive in self-hosted entitlement state.
Both gaps are addressed before publishing RC4. The corrected tests assert the
canonical root contract: self-hosted v6 plans preserve paid continuity without
monitored-system volume caps, and `max_monitored_systems` remains only as
legacy metadata to scrub.
No public issue comment, retitle, closure, or customer-facing message was made
as part of this packet update.
@ -112,3 +135,8 @@ as part of this packet update.
- `PYTHONPATH=scripts/release_control python3 -m unittest scripts.release_control.release_promotion_policy_test.ReleasePromotionPolicyTest.test_release_notes_index_points_at_current_rc_packet scripts.release_control.release_promotion_policy_test.ReleasePromotionPolicyTest.test_operator_support_packs_keep_free_first_paid_continuity_wording scripts.release_control.release_promotion_policy_test.ReleasePromotionPolicyTest.test_version_file_matches_current_rc_packet scripts.release_control.release_promotion_policy_test.ReleasePromotionPolicyTest.test_upgrade_guide_points_at_current_rc_support_pack -q`
- `python3 scripts/release_control/resolve_release_promotion.py --version 6.0.0-rc.4 --rollback-version v5.1.29 --release-notes-file docs/releases/RELEASE_NOTES_v6_RC4_DRAFT.md`
- `python3 scripts/release_control/status_audit.py --pretty`
- Draft release workflow `25382802275`:
- `prepare`, `frontend_checks`, `helm_smoke`, and `docker_build` passed
- `backend_tests` failed on stale RC3 Docker defaults and stale retired
monitored-system cap expectations
- `go test -race ./internal/config ./tests/migration ./scripts/installtests`

View file

@ -8,25 +8,22 @@
1. The latest shipped Pulse v6 prerelease tag is `v6.0.0-rc.3`.
2. That shipped prerelease tag resolves to commit `f1744d36d0bde3c8735ae75a190af45c35087841`.
3. The selected remote ref `origin/pulse/v6-release` is still behind the current
local governed branch state, so `Release Dry Run` would exercise stale remote
control-plane metadata instead of the intended candidate.
4. The governed release profile in `docs/release-control/control_plane.json`
3. The governed release profile in `docs/release-control/control_plane.json`
currently declares both `prerelease_branch` and `stable_branch` as
`pulse/v6-release`.
5. The active control-plane target is still `v6-product-lane-expansion`, not
4. The active control-plane target is still `v6-product-lane-expansion`, not
`v6-ga-promotion`.
6. The active local `pulse/v6-release` branch currently reports `VERSION=6.0.0-rc.4`, so the
5. The active local `pulse/v6-release` branch currently reports `VERSION=6.0.0-rc.4`, so the
working line is still prerelease and there is not yet a governed local stable
`6.0.0` candidate.
7. There is still no governed `Prerelease-to-GA Rehearsal Record` proving a successful
6. There is still no governed `Prerelease-to-GA Rehearsal Record` proving a successful
non-publish `Release Dry Run` for the eventual stable `6.0.0` candidate.
8. `docs/releases/RELEASE_NOTES_v6.md` and
7. `docs/releases/RELEASE_NOTES_v6.md` and
`docs/release-control/v6/internal/V5_MAINTENANCE_SUPPORT_POLICY.md` now carry the
currently proposed exact dates for the eventual GA notice:
- `v6` GA date: `2026-04-20`
- `v5` end-of-support date: `2026-07-19`
9. There is still no governed `Release Dry Run` artifact or rehearsal record
8. There is still no governed `Release Dry Run` artifact or rehearsal record
exercising stable inputs for:
- `version=6.0.0`
- `promoted_from_tag=v6.0.0-rc.3`

View file

@ -367,6 +367,13 @@ root `docker-compose.yml` sample and `scripts/install-docker.sh` must default
to the governed `VERSION` cut instead of floating `:latest`, so self-hosted
operators only move to a newer image when they choose a newer explicit tag or
override `PULSE_IMAGE`.
For every RC or stable release cut, those Docker defaults must move with the
same governed `VERSION` change and the installer proof in
`scripts/installtests/install_docker_sh_test.go` must assert both the repo-root
compose image default and the standalone installer fallback constant. A draft
release workflow failure caused by stale Docker image pins is a release-packet
blocker until the defaults, tests, and evidence record are refreshed from the
new branch head.
`internal/updates/` is the live deployment and upgrade planner. It owns
deployment-type detection, update-plan generation, adapter selection, server

View file

@ -24,10 +24,13 @@ hardening into a retestable v6 candidate:
- Workloads empty-state detection, Patrol mobile header controls, mock-mode
legacy sidecar cleanup, and agent-security guidance were refreshed
This packet was audited against all `51` commits in the exact `rc.3` to `rc.4`
candidate range, from the published `v6.0.0-rc.3` tag commit
This packet was audited against all `51` feature and runtime commits in the
exact `rc.3` to `rc.4` candidate range, from the published `v6.0.0-rc.3` tag commit
`f1744d36d0bde3c8735ae75a190af45c35087841` through candidate commit
`3f16d7845a92d6bf0c5700728bd70e1f4fe32966`.
`3f16d7845a92d6bf0c5700728bd70e1f4fe32966`. The final prerelease target also
includes RC4 packet and release-validation commits that set the governed
version, pin Docker install defaults to `6.0.0-rc.4`, and align migration tests
with the canonical self-hosted licensing contract.
## Support Stance
@ -97,6 +100,8 @@ candidate range, from the published `v6.0.0-rc.3` tag commit
- The Agent Security documentation entry now points operators at the current
privilege guidance without leaving a stale support-pack reference.
- Public demo admin reads stay hidden from the demo surface.
- Docker Compose and turnkey Docker installer defaults now pin the RC4 image
tag instead of the historical RC3 tag.
## What Existing v5 Users Should Re-Test In `rc.4`

View file

@ -21,8 +21,8 @@ match the current governed v6 architecture before wider RC retesting.
## Commit Coverage Audit
The changelog was audited against every commit in the exact release range for
the current candidate head:
The changelog was audited against every feature/runtime commit in the exact
release range for the current candidate head:
- `v6.0.0-rc.3`: `f1744d36d0bde3c8735ae75a190af45c35087841`
- candidate commit: `3f16d7845a92d6bf0c5700728bd70e1f4fe32966`
@ -36,7 +36,9 @@ cleanup, API-first action planning, CLI action and fleet reads, action audit
execution proof, self-hosted licensing continuity, root-agent and Proxmox
setup hardening, TrueNAS/RAID/Ceph/storage correctness, Workloads empty-state
handling, Patrol mobile controls, mock-mode cleanup, and release-control
evidence.
evidence. The final RC4 prerelease target also includes packet and
release-validation commits that pin Docker install defaults to `6.0.0-rc.4` and
remove stale migration-test expectations for retired monitored-system caps.
## Major Changes

View file

@ -366,9 +366,8 @@ func TestBillingState_SaveCanonicalizesCloudPlanContract(t *testing.T) {
require.NoError(t, err)
require.NotNil(t, loaded)
assert.Equal(t, "cloud_starter", loaded.PlanVersion)
assert.Equal(t, int64(10), loaded.Limits[pkglicensing.MaxMonitoredSystemsLicenseGateKey])
_, hasOld := loaded.Limits["max_nodes"]
assert.False(t, hasOld)
assert.NotContains(t, loaded.Limits, pkglicensing.MaxMonitoredSystemsLicenseGateKey)
assert.NotContains(t, loaded.Limits, "max_nodes")
data, err := os.ReadFile(filepath.Join(dir, "billing.json"))
require.NoError(t, err)
@ -378,9 +377,8 @@ func TestBillingState_SaveCanonicalizesCloudPlanContract(t *testing.T) {
assert.Equal(t, "cloud_starter", raw["plan_version"])
rawLimits, ok := raw["limits"].(map[string]any)
require.True(t, ok)
assert.Equal(t, float64(10), rawLimits[pkglicensing.MaxMonitoredSystemsLicenseGateKey])
_, hasOld = rawLimits["max_nodes"]
assert.False(t, hasOld)
assert.NotContains(t, rawLimits, pkglicensing.MaxMonitoredSystemsLicenseGateKey)
assert.NotContains(t, rawLimits, "max_nodes")
}
func TestBillingState_GrandfatheredRecurringSelfHostedPlanStaysUncapped(t *testing.T) {
@ -487,7 +485,7 @@ func TestBillingState_AllFieldsSurviveRoundTrip(t *testing.T) {
// Every field must survive save → reload → HMAC verify.
assert.ElementsMatch(t, []string{"relay", "ai_autofix"}, loaded.Capabilities)
assert.Equal(t, map[string]int64{pkglicensing.MaxMonitoredSystemsLicenseGateKey: 50, "max_hosts": 100}, loaded.Limits)
assert.Equal(t, map[string]int64{"max_hosts": 100}, loaded.Limits)
assert.ElementsMatch(t, []string{"active_agents", "api_calls"}, loaded.MetersEnabled)
assert.Equal(t, "pro-v2", loaded.PlanVersion)
assert.Equal(t, entitlements.SubStateActive, loaded.SubscriptionState)

View file

@ -6,7 +6,7 @@ set -euo pipefail
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
DOCKER_IMAGE_REPO="${DOCKER_IMAGE_REPO:-rcourtman/pulse}"
CANONICAL_DEFAULT_PULSE_VERSION="6.0.0-rc.3"
CANONICAL_DEFAULT_PULSE_VERSION="6.0.0-rc.4"
resolve_default_pulse_version() {
if [ -n "${PULSE_IMAGE_VERSION:-}" ]; then

View file

@ -97,6 +97,19 @@ func TestRepoDockerComposeDefaultPinsCurrentVersion(t *testing.T) {
}
}
func TestInstallDockerScriptFallbackPinsCurrentVersion(t *testing.T) {
version := currentReleaseVersion(t)
content, err := os.ReadFile(repoFile("scripts", "install-docker.sh"))
if err != nil {
t.Fatalf("read install-docker.sh: %v", err)
}
text := string(content)
if !strings.Contains(text, `CANONICAL_DEFAULT_PULSE_VERSION="`+version+`"`) {
t.Fatalf("install-docker.sh fallback must pin the current release version:\n%s", text)
}
}
func runInstallDockerScript(t *testing.T, workDir string, envVars ...string) {
t.Helper()

View file

@ -152,6 +152,9 @@ Old metadata section.
self.assertIn("API-first action planning", changelog)
self.assertIn("monitored-system and child-resource volume unmetered", release_notes)
self.assertIn("Pulse Mobile pairing for handoff", support_pack)
self.assertIn("pin Docker install defaults to `6.0.0-rc.4`", changelog)
self.assertIn("Docker Compose and turnkey Docker installer defaults", release_notes)
self.assertIn("release-validation commits", changelog)
if __name__ == "__main__":

View file

@ -448,15 +448,14 @@ func TestV5FullUpgradeScenario(t *testing.T) {
require.NoError(t, persistence.Save(legacyLicense))
grantJWT, grantPublicKey, err := pkglicensing.GenerateGrantJWTForTesting(pkglicensing.GrantClaims{
LicenseID: "lic_v5_migrated",
Tier: string(pkglicensing.TierLifetime),
PlanKey: "v5_lifetime_grandfathered",
State: "active",
Features: append([]string(nil), pkglicensing.TierFeatures[pkglicensing.TierLifetime]...),
MaxMonitoredSystems: pkglicensing.TierMonitoredSystemLimits[pkglicensing.TierLifetime],
IssuedAt: time.Now().Unix(),
ExpiresAt: time.Now().Add(72 * time.Hour).Unix(),
Email: "legacy-lifetime@example.com",
LicenseID: "lic_v5_migrated",
Tier: string(pkglicensing.TierLifetime),
PlanKey: "v5_lifetime_grandfathered",
State: "active",
Features: append([]string(nil), pkglicensing.TierFeatures[pkglicensing.TierLifetime]...),
IssuedAt: time.Now().Unix(),
ExpiresAt: time.Now().Add(72 * time.Hour).Unix(),
Email: "legacy-lifetime@example.com",
})
require.NoError(t, err)
pkglicensing.SetPublicKey(grantPublicKey)
@ -472,11 +471,10 @@ func TestV5FullUpgradeScenario(t *testing.T) {
w.WriteHeader(http.StatusCreated)
require.NoError(t, json.NewEncoder(w).Encode(pkglicensing.ActivateInstallationResponse{
License: pkglicensing.ActivateResponseLicense{
LicenseID: "lic_v5_migrated",
State: "active",
Tier: string(pkglicensing.TierLifetime),
Features: append([]string(nil), pkglicensing.TierFeatures[pkglicensing.TierLifetime]...),
MaxMonitoredSystems: pkglicensing.TierMonitoredSystemLimits[pkglicensing.TierLifetime],
LicenseID: "lic_v5_migrated",
State: "active",
Tier: string(pkglicensing.TierLifetime),
Features: append([]string(nil), pkglicensing.TierFeatures[pkglicensing.TierLifetime]...),
},
Installation: pkglicensing.ActivateResponseInstallation{
InstallationID: "inst_v5_migrated",
@ -587,15 +585,14 @@ func TestV5FullUpgradeScenario(t *testing.T) {
require.NoError(t, persistence.Save(legacyLicense))
grantJWT, grantPublicKey, err := pkglicensing.GenerateGrantJWTForTesting(pkglicensing.GrantClaims{
LicenseID: tc.licenseID,
Tier: string(pkglicensing.TierPro),
PlanKey: tc.planKey,
State: "active",
Features: append([]string(nil), pkglicensing.TierFeatures[pkglicensing.TierPro]...),
MaxMonitoredSystems: pkglicensing.TierMonitoredSystemLimits[pkglicensing.TierPro],
IssuedAt: time.Now().Unix(),
ExpiresAt: time.Now().Add(72 * time.Hour).Unix(),
Email: tc.email,
LicenseID: tc.licenseID,
Tier: string(pkglicensing.TierPro),
PlanKey: tc.planKey,
State: "active",
Features: append([]string(nil), pkglicensing.TierFeatures[pkglicensing.TierPro]...),
IssuedAt: time.Now().Unix(),
ExpiresAt: time.Now().Add(72 * time.Hour).Unix(),
Email: tc.email,
})
require.NoError(t, err)
pkglicensing.SetPublicKey(grantPublicKey)
@ -611,11 +608,10 @@ func TestV5FullUpgradeScenario(t *testing.T) {
w.WriteHeader(http.StatusCreated)
require.NoError(t, json.NewEncoder(w).Encode(pkglicensing.ActivateInstallationResponse{
License: pkglicensing.ActivateResponseLicense{
LicenseID: tc.licenseID,
State: "active",
Tier: string(pkglicensing.TierPro),
Features: append([]string(nil), pkglicensing.TierFeatures[pkglicensing.TierPro]...),
MaxMonitoredSystems: pkglicensing.TierMonitoredSystemLimits[pkglicensing.TierPro],
LicenseID: tc.licenseID,
State: "active",
Tier: string(pkglicensing.TierPro),
Features: append([]string(nil), pkglicensing.TierFeatures[pkglicensing.TierPro]...),
},
Installation: pkglicensing.ActivateResponseInstallation{
InstallationID: tc.installID,

View file

@ -128,7 +128,7 @@ func TestV5PaidLicenseUpgrade_RealLicenseServerExchange(t *testing.T) {
assert.NotEqual(t, tc.licenseID, current.Claims.LicenseID, "real exchange should promote the legacy token into a new canonical v6 license id")
assert.Equal(t, tc.tier, current.Claims.Tier)
assert.Equal(t, tc.planKey, current.Claims.PlanVersion)
assert.Equal(t, pkglicensing.TierMonitoredSystemLimits[tc.tier], current.Claims.MaxMonitoredSystems)
assertMonitoredSystemLimitAbsent(t, current.Claims.EffectiveLimits())
activationState, err := persistence.LoadActivationState()
require.NoError(t, err)
@ -154,7 +154,6 @@ func TestV5PaidLicenseUpgrade_RealLicenseServerExchange(t *testing.T) {
assert.Equal(t, tc.tier, status.Tier)
assert.Equal(t, tc.planKey, status.PlanVersion)
assert.Equal(t, tc.wantIsLifetime, status.IsLifetime)
assert.Equal(t, pkglicensing.TierMonitoredSystemLimits[tc.tier], status.MaxMonitoredSystems)
entReq := httpRequestWithOrg(http.MethodGet, "/api/license/entitlements", ctx)
entRec := responseRecorder()
@ -167,7 +166,7 @@ func TestV5PaidLicenseUpgrade_RealLicenseServerExchange(t *testing.T) {
assert.Equal(t, "active", entitlements.SubscriptionState)
assert.Equal(t, string(tc.tier), entitlements.Tier)
assert.Equal(t, tc.wantIsLifetime, entitlements.IsLifetime)
assert.Equal(t, int64(pkglicensing.TierMonitoredSystemLimits[tc.tier]), entitlementLimitByKey(entitlements.Limits, pkglicensing.MaxMonitoredSystemsLicenseGateKey))
assertEntitlementLimitAbsent(t, entitlements.Limits, pkglicensing.MaxMonitoredSystemsLicenseGateKey)
})
}
}
@ -180,13 +179,18 @@ func responseRecorder() *httptest.ResponseRecorder {
return httptest.NewRecorder()
}
func entitlementLimitByKey(limits []pkglicensing.LimitStatus, key string) int64 {
func assertMonitoredSystemLimitAbsent(t *testing.T, limits map[string]int64) {
t.Helper()
assert.NotContains(t, limits, pkglicensing.MaxMonitoredSystemsLicenseGateKey)
}
func assertEntitlementLimitAbsent(t *testing.T, limits []pkglicensing.LimitStatus, key string) {
t.Helper()
for _, limit := range limits {
if limit.Key == key {
return limit.Limit
t.Fatalf("entitlement limit %q present, want absent: %+v", key, limits)
}
}
return 0
}
func managedLicenseServerDir(t *testing.T) string {
@ -229,21 +233,21 @@ func managedLicenseServerPlansJSON(t *testing.T) string {
"tier": string(pkglicensing.TierLifetime),
"duration_days": 0,
"features": pkglicensing.TierFeatures[pkglicensing.TierLifetime],
"max_monitored_systems": pkglicensing.TierMonitoredSystemLimits[pkglicensing.TierLifetime],
"max_monitored_systems": 0,
"max_guests": 5,
},
"v5_pro_monthly_grandfathered": {
"tier": string(pkglicensing.TierPro),
"duration_days": 30,
"features": pkglicensing.TierFeatures[pkglicensing.TierPro],
"max_monitored_systems": pkglicensing.TierMonitoredSystemLimits[pkglicensing.TierPro],
"max_monitored_systems": 0,
"max_guests": 5,
},
"v5_pro_annual_grandfathered": {
"tier": string(pkglicensing.TierPro),
"duration_days": 365,
"features": pkglicensing.TierFeatures[pkglicensing.TierPro],
"max_monitored_systems": pkglicensing.TierMonitoredSystemLimits[pkglicensing.TierPro],
"max_monitored_systems": 0,
"max_guests": 5,
},
}