Restore v6 development version semantics

This commit is contained in:
rcourtman 2026-04-04 23:02:58 +01:00
parent 7e0f84a40f
commit 283f2e25ae
20 changed files with 323 additions and 29 deletions

View file

@ -1 +1 @@
6.0.0-rc.1
6.0.0-dev

View file

@ -17,7 +17,7 @@
`pulse/v6-release`.
5. The active control-plane target is still `v6-rc-cut`, not
`v6-ga-promotion`.
6. The active local `pulse/v6-release` branch currently reports `VERSION=6.0.0-rc.1`, so the
6. The active local `pulse/v6-release` branch currently reports `VERSION=6.0.0-dev`, 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
@ -45,7 +45,7 @@
The blocker is no longer missing governance text. The remaining problem is that
the control plane still holds v6 on the pre-GA prerelease line, the working
version is still prerelease (`6.0.0-rc.1`), and there is still no exercised
version is still prerelease (`6.0.0-dev`), and there is still no exercised
`Release Dry Run` record proving the eventual stable `6.0.0`
candidate is ready for GA-style promotion. Until that rehearsal exists, stable
users would still be the first real cohort for the final promotion path.

View file

@ -183,6 +183,13 @@ management, and fleet control surfaces.
11. Keep shared `internal/api/router.go` workload-chart downsampling presentation-only: when that router caps mixed-cadence workload history into equal-time buckets for operator-facing cards, lifecycle-adjacent setup and fleet surfaces must not reuse the shaped chart samples as heartbeat, enrollment, or last-seen authority.
That same presentation-only boundary must preserve canonical millisecond timestamps when it serializes chart points, so lifecycle-adjacent first-host and fleet surfaces do not misread rounded chart samples as duplicate or restarted heartbeat evidence.
The same rule now applies to storage summary interaction. Shared sticky-card or row-hover focus behavior on infrastructure, workloads, and storage may reuse the canonical chart transport, but lifecycle-adjacent install, enrollment, and fleet surfaces must not treat highlighted summary series or sticky-shell state as agent freshness or setup progress.
12. Keep lifecycle installer fallback pinned to published release lineage only.
When `internal/api/unified_agent.go` has to proxy `/install.sh` or
`/install.ps1` from GitHub, the shared lifecycle path may only treat stable
tags and explicit RC prerelease tags as release assets. Working-line dev
prereleases and build-metadata versions must fail closed so first-host
install, repair, and fleet continuity do not depend on unpublished or
branch-local installer URLs.
## Forbidden Paths

View file

@ -341,6 +341,14 @@ Own canonical runtime payload shapes between backend and frontend.
route API-backed first systems to `/settings/infrastructure/platforms`
instead of implying that a host install command is required before those
platforms can report into Pulse.
24. Keep shared install-script fallback transport pinned to published release
lineage. `internal/api/unified_agent.go` and
`internal/api/contract_test.go` must only map stable tags or explicit RC
prerelease tags without build metadata to GitHub install-script release
assets; dev prereleases such as `v6.0.0-dev`, git-described
`+git...` builds, and other unpublished prerelease identifiers must fail
closed on that API boundary instead of generating fake release URLs from
a local runtime version string.
23. Keep local trial-start transport explicit on the shared commercial API
boundary: `/api/license/trial/start` must preserve the hosted-signup
redirect contract during the allowed retry burst, then return the actual
@ -1453,6 +1461,13 @@ contract: when `/install.sh` or `/install.ps1` cannot be served locally, the
backend must proxy the install script asset from the exact GitHub release that
matches `serverVersion` and must fail closed for dev or unreleased builds
rather than serving branch-tip installer logic.
That same transport rule is now explicit about prerelease classes too: only
stable tags and explicit RC prerelease tags without build metadata qualify as
published install-script release assets. Working-line dev prereleases such as
`v6.0.0-dev`, git-described builds with `+git...` metadata, and other
non-published prerelease identifiers must fail closed on that shared
`internal/api/unified_agent.go` boundary instead of generating fake GitHub
release URLs from a local runtime version string.
The `/api/updates/plan` contract must also fail closed without becoming a
transport error on supported non-auto-update deployments: `manual`,
`development`, and `source` runtimes must return an explicit manual update

View file

@ -124,6 +124,14 @@ fallbacks, so shipped release builds report the exact promoted version even
when the install path has no `.git` metadata or a stale `VERSION` file nearby.
Runtime bootstrap must seed that build version before the server starts rather
than leaving version detection to deployment-local filesystem guesses.
That same version boundary now also owns the working-line development base:
the checked-in `VERSION` file is the canonical intended semver base for
current v6 development, and source/dev runtime detection must append git build
metadata to that base instead of inheriting accidental prerelease tag lineage
from `git describe`. Non-published prerelease bases such as `6.0.0-dev`
therefore remain prerelease for branch policy, release-control blocked
records, and future release promotion planning, but they must not be treated
as shipped RC lineage or as published release-asset versions.
That release-build metadata path is now explicit too: `scripts/release_ldflags.sh`
is the canonical owner for server and agent build ldflags, and release artifact
assembly must route through it instead of hand-writing overlapping `main.Version`,

View file

@ -212,6 +212,13 @@ querying, and the operator-facing storage health presentation layer.
snapshot-backed platforms, provider-backed fixtures, unified inventory,
and recovery/storage context stay aligned instead of drifting through
recovery-local fixture assembly or partial mock helper APIs.
12. Keep adjacent shared install-script fallback semantics honest on the
`internal/api/` boundary. When storage- or recovery-adjacent routes reuse
shared public endpoint or installer helpers, dev prerelease runtime
versions such as `v6.0.0-dev` and build-metadata versions must not be
treated as published GitHub release assets; only stable or explicit RC
tags may back the shared installer fallback that those adjacent surfaces
inherit.
12. Keep storage summary chart identity and sticky-shell behavior on the
shared storage path. Pool rows, disk rows, storage summary cards, and
storage detail charts must all address history through the canonical

View file

@ -5211,6 +5211,14 @@ func TestContract_InstallScriptReleaseAssetURLRejectsUnreleasedBuild(t *testing.
}
}
func TestContract_InstallScriptReleaseAssetURLRejectsDevPrereleaseBuild(t *testing.T) {
router := &Router{serverVersion: "v6.0.0-dev"}
if _, err := router.installScriptReleaseAssetURL("install.sh"); err == nil {
t.Fatalf("expected dev prerelease build to reject release asset lookup")
}
}
func TestContract_ProxmoxInstallCommandIncludesInsecureForPlainHTTP(t *testing.T) {
got := buildProxmoxAgentInstallCommand(agentInstallCommandOptions{
BaseURL: "http://pulse.example.com:7655/",

View file

@ -468,8 +468,8 @@ func (r *Router) installScriptReleaseAssetURL(scriptName string) (string, error)
if err != nil {
return "", fmt.Errorf("server version %q is not a published release version", rawVersion)
}
if version.Build != "" {
return "", fmt.Errorf("server version %q includes build metadata and cannot map to a release asset", rawVersion)
if !version.IsPublishedReleaseAssetVersion() {
return "", fmt.Errorf("server version %q is not a published release asset version", rawVersion)
}
return fmt.Sprintf(

View file

@ -192,6 +192,23 @@ func TestDownloadUnifiedInstallScript_ProxyFallbackRejectsDevelopmentBuild(t *te
}
}
func TestDownloadUnifiedInstallScript_ProxyFallbackRejectsDevPrereleaseBuild(t *testing.T) {
router, _ := setupUnifiedAgentRouter(t)
router.serverVersion = "v6.0.0-dev"
req := httptest.NewRequest(http.MethodGet, "/install.sh", nil)
w := httptest.NewRecorder()
router.handleDownloadUnifiedInstallScript(w, req)
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("expected 503, got %d", w.Code)
}
if !strings.Contains(w.Body.String(), "Install script unavailable for current server build") {
t.Fatalf("unexpected response body: %q", w.Body.String())
}
}
func TestDownloadUnifiedInstallScript_ProxyFallbackPreservesHEAD(t *testing.T) {
router, _ := setupUnifiedAgentRouter(t)
router.serverVersion = "v6.0.0-rc.1"

View file

@ -16,8 +16,9 @@ import (
// Pre-compiled regexes for performance (avoid recompilation on each call)
var (
semverRe = regexp.MustCompile(`^(\d+)\.(\d+)\.(\d+)(?:-([^+]+))?(?:\+(.+))?$`)
rcNumRe = regexp.MustCompile(`rc\.?(\d+)`)
semverRe = regexp.MustCompile(`^(\d+)\.(\d+)\.(\d+)(?:-([^+]+))?(?:\+(.+))?$`)
rcNumRe = regexp.MustCompile(`rc\.?(\d+)`)
gitHashRe = regexp.MustCompile(`^([0-9a-fA-F]{7,})(-dirty)?$`)
)
// BuildVersion is the version string injected at build time via ldflags.
@ -34,6 +35,23 @@ type Version struct {
Build string
}
func (v *Version) IsRCPrerelease() bool {
if v == nil {
return false
}
return rcNumRe.MatchString(strings.ToLower(strings.TrimSpace(v.Prerelease)))
}
func (v *Version) IsPublishedReleaseAssetVersion() bool {
if v == nil {
return false
}
if v.Build != "" {
return false
}
return v.Prerelease == "" || v.IsRCPrerelease()
}
// VersionInfo contains detailed version information
type VersionInfo struct {
Version string `json:"version"`
@ -132,6 +150,7 @@ func (v *Version) IsPrerelease() bool {
// GetCurrentVersion gets the current running version
func GetCurrentVersion() (*VersionInfo, error) {
allowDockerUpdates := dockerUpdatesAllowed()
versionFileRaw, hasVersionFile := readCurrentVersionFile()
buildInfo := func(raw string, build string, isDev bool) *VersionInfo {
normalized := normalizeVersionString(raw)
@ -160,23 +179,12 @@ func GetCurrentVersion() (*VersionInfo, error) {
}
if gitVersion, err := getGitVersion(); err == nil && gitVersion != "" {
return buildInfo(gitVersion, "development", true), nil
return buildInfo(normalizeDevelopmentVersion(gitVersion, versionFileRaw), "development", true), nil
}
// Try to read from VERSION file first (release builds)
versionPaths := []string{
"VERSION",
"/opt/pulse/VERSION",
filepath.Join(filepath.Dir(os.Args[0]), "VERSION"),
}
for _, path := range versionPaths {
versionBytes, err := os.ReadFile(path)
if err == nil {
if raw := strings.TrimSpace(string(versionBytes)); raw != "" {
return buildInfo(raw, "release", false), nil
}
}
if hasVersionFile {
return buildInfo(versionFileRaw, "release", false), nil
}
// Fall back to git (development builds)
@ -211,9 +219,49 @@ func normalizeVersionString(version string) string {
return fmt.Sprintf("0.0.0-%s", sanitizePrereleaseIdentifier(version))
}
func normalizeDevelopmentVersion(gitVersion string, versionBase string) string {
normalizedGit := normalizeVersionString(gitVersion)
normalizedBase := normalizeVersionString(versionBase)
if versionBase == "" {
return normalizedGit
}
if _, err := ParseVersion(normalizedBase); err != nil {
return normalizedGit
}
if buildMetadata, ok := gitBuildMetadata(gitVersion); ok {
if strings.Contains(normalizedBase, "+") {
return normalizedBase + "." + buildMetadata
}
return normalizedBase + "+" + buildMetadata
}
return normalizedBase
}
func gitBuildMetadata(version string) (string, bool) {
if normalized, ok := normalizeGitDescribeVersion(version); ok {
if plus := strings.Index(normalized, "+"); plus >= 0 && plus < len(normalized)-1 {
return normalized[plus+1:], true
}
}
matches := gitHashRe.FindStringSubmatch(strings.TrimSpace(version))
if matches == nil {
return "", false
}
build := "git.0.g" + strings.ToLower(matches[1])
if matches[2] != "" {
build += ".dirty"
}
return build, true
}
var gitDescribeRegex = regexp.MustCompile(`^(\d+\.\d+\.\d+(?:-[0-9A-Za-z\.-]+)?)-(\d+)-g([0-9a-fA-F]+)(-dirty)?$`)
func normalizeGitDescribeVersion(version string) (string, bool) {
version = strings.TrimSpace(strings.TrimPrefix(version, "v"))
matches := gitDescribeRegex.FindStringSubmatch(version)
if matches == nil {
return "", false
@ -270,13 +318,35 @@ func sanitizePrereleaseIdentifier(raw string) string {
}
func detectChannelFromVersion(version string) string {
versionLower := strings.ToLower(version)
if strings.Contains(versionLower, "rc") {
return "rc"
trimmed := strings.TrimSpace(version)
if parsed, err := ParseVersion(trimmed); err == nil {
if parsed.IsRCPrerelease() {
return "rc"
}
return "stable"
}
return "stable"
}
func readCurrentVersionFile() (string, bool) {
versionPaths := []string{
"VERSION",
"/opt/pulse/VERSION",
filepath.Join(filepath.Dir(os.Args[0]), "VERSION"),
}
for _, path := range versionPaths {
versionBytes, err := os.ReadFile(path)
if err == nil {
if raw := strings.TrimSpace(string(versionBytes)); raw != "" {
return raw, true
}
}
}
return "", false
}
// getGitVersion gets version information from git
func getGitVersion() (string, error) {
// Get the latest tag

View file

@ -76,6 +76,57 @@ func TestGetCurrentVersion_UsesVersionFile(t *testing.T) {
}
}
func TestGetCurrentVersion_UsesVersionFileAsDevelopmentBase(t *testing.T) {
oldwd, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
defer func() {
_ = os.Chdir(oldwd)
}()
oldBuildVersion := BuildVersion
BuildVersion = ""
defer func() {
BuildVersion = oldBuildVersion
}()
tmpDir := t.TempDir()
if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("chdir: %v", err)
}
gitPath := filepath.Join(tmpDir, "git")
gitScript := "#!/bin/sh\nprintf '%s\\n' 'v6.0.0-rc.1-45-gABCDEF'\n"
if err := os.WriteFile(gitPath, []byte(gitScript), 0755); err != nil {
t.Fatalf("write fake git: %v", err)
}
if err := os.WriteFile(filepath.Join(tmpDir, "VERSION"), []byte("6.0.0-dev"), 0644); err != nil {
t.Fatalf("write VERSION: %v", err)
}
t.Setenv("PATH", tmpDir)
t.Setenv("PULSE_MOCK_MODE", "")
t.Setenv("PULSE_ALLOW_DOCKER_UPDATES", "")
info, err := GetCurrentVersion()
if err != nil {
t.Fatalf("GetCurrentVersion error: %v", err)
}
if info.Version != "6.0.0-dev+git.45.gabcdef" {
t.Fatalf("Version = %q, want 6.0.0-dev+git.45.gabcdef", info.Version)
}
if info.Build != "development" {
t.Fatalf("Build = %q, want development", info.Build)
}
if !info.IsDevelopment {
t.Fatalf("IsDevelopment = false, want true")
}
if info.Channel != "stable" {
t.Fatalf("Channel = %q, want stable", info.Channel)
}
}
func TestGetDeploymentType_Mock(t *testing.T) {
t.Setenv("PULSE_MOCK_MODE", "true")
if got := GetDeploymentType(); got != "mock" {

View file

@ -25,6 +25,11 @@ func TestNormalizeVersionString(t *testing.T) {
input: "4.24.0-rc.3-45-gabc123-dirty",
expected: "4.24.0-rc.3+git.45.gabc123.dirty",
},
{
name: "development base rebases accidental prerelease lineage",
input: "6.0.0-dev",
expected: "6.0.0-dev",
},
{
name: "branch name falls back to prerelease",
input: "issue-551",
@ -142,11 +147,84 @@ func TestDetectChannelFromVersion(t *testing.T) {
t.Fatalf("detectChannelFromVersion rc = %s, expected rc", got)
}
if got := detectChannelFromVersion("6.0.0-dev+git.45.gabcdef"); got != "stable" {
t.Fatalf("detectChannelFromVersion dev = %s, expected stable", got)
}
if got := detectChannelFromVersion("0.0.0-feature-x"); got != "stable" {
t.Fatalf("detectChannelFromVersion stable = %s, expected stable", got)
}
}
func TestNormalizeDevelopmentVersion(t *testing.T) {
t.Parallel()
tests := []struct {
name string
gitVersion string
versionBase string
expected string
}{
{
name: "git describe inherits current version base not accidental tag",
gitVersion: "6.0.0-rc.1-45-gABCDEF",
versionBase: "6.0.0-dev",
expected: "6.0.0-dev+git.45.gabcdef",
},
{
name: "hash only git version still carries build metadata",
gitVersion: "ABCDEF1-dirty",
versionBase: "6.0.0-dev",
expected: "6.0.0-dev+git.0.gabcdef1.dirty",
},
{
name: "missing base falls back to normalized git version",
gitVersion: "6.0.0-rc.1-45-gABCDEF",
versionBase: "",
expected: "6.0.0-rc.1+git.45.gabcdef",
},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := normalizeDevelopmentVersion(tc.gitVersion, tc.versionBase); got != tc.expected {
t.Fatalf("normalizeDevelopmentVersion(%q, %q) = %q, expected %q", tc.gitVersion, tc.versionBase, got, tc.expected)
}
})
}
}
func TestVersionIsPublishedReleaseAssetVersion(t *testing.T) {
t.Parallel()
tests := []struct {
name string
version string
expected bool
}{
{name: "stable release", version: "6.0.0", expected: true},
{name: "rc prerelease", version: "6.0.0-rc.1", expected: true},
{name: "dev prerelease", version: "6.0.0-dev", expected: false},
{name: "build metadata", version: "6.0.0+git.1.gabcdef", expected: false},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
parsed, err := ParseVersion(tc.version)
if err != nil {
t.Fatalf("ParseVersion(%q) error: %v", tc.version, err)
}
if got := parsed.IsPublishedReleaseAssetVersion(); got != tc.expected {
t.Fatalf("IsPublishedReleaseAssetVersion(%q) = %v, expected %v", tc.version, got, tc.expected)
}
})
}
}
func TestVersionString(t *testing.T) {
t.Parallel()

View file

@ -66,7 +66,7 @@ PROFILE_PATH_FIELDS = {
"registry_schema",
"subsystem_contract_template",
}
PRERELEASE_VERSION_PATTERN = re.compile(r"-(?:rc|alpha|beta)\.[0-9]+$")
PRERELEASE_VERSION_PATTERN = re.compile(r"-(?:[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)(?:\+[0-9A-Za-z.-]+)?$")
COMPLETION_RULE_BLOCKING_LEVELS = {
"repo_ready": ("repo-ready",),
"rc_ready": ("repo-ready", "rc-ready"),

View file

@ -168,6 +168,7 @@ class ControlPlaneAuditTest(unittest.TestCase):
def test_release_branch_for_version_uses_profile_branch_policy(self) -> None:
self.assertEqual(release_branch_for_version("6.0.0-rc.1", control_plane=VALID_PAYLOAD), "pulse/v6-release")
self.assertEqual(release_branch_for_version("6.0.0-dev", control_plane=VALID_PAYLOAD), "pulse/v6-release")
self.assertEqual(release_branch_for_version("6.0.0", control_plane=VALID_PAYLOAD), "pulse/v6-release")
def test_audit_flags_stale_active_target(self) -> None:

View file

@ -13,7 +13,7 @@ from typing import Callable
from repo_file_io import REPO_ROOT, git_env
SEMVER_PRERELEASE_RE = re.compile(r"-(?:rc|alpha|beta)\.\d+$")
SEMVER_PRERELEASE_RE = re.compile(r"-(?:[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)(?:\+[0-9A-Za-z.-]+)?$")
def normalize_tag(value: str) -> str:

View file

@ -9,6 +9,22 @@ import resolve_release_promotion as resolver
class ResolveReleasePromotionTest(unittest.TestCase):
def test_dev_prerelease_uses_prerelease_path(self) -> None:
metadata = resolver.resolve_metadata(
version="6.0.0-dev",
promoted_from_tag_input="",
rollback_version_input="5.1.14",
ga_date_input="",
v5_eos_date_input="",
hotfix_exception=False,
hotfix_reason_input="",
release_notes_input="",
tag_exists_fn=lambda tag: tag == "v5.1.14",
)
self.assertEqual(metadata["rollback_tag"], "v5.1.14")
self.assertEqual(metadata["promoted_from_tag"], "")
self.assertEqual(metadata["soak_hours"], "")
def test_prerelease_requires_explicit_stable_rollback(self) -> None:
metadata = resolver.resolve_metadata(
version="6.0.0-rc.2",

View file

@ -20,7 +20,7 @@ GA_DATE_RE = re.compile(
V5_EOS_RE = re.compile(
r"existing v5 users until `(?P<v5_eos_date>\[v5-eos-date\]|\d{4}-\d{2}-\d{2})`\."
)
PRERELEASE_RE = re.compile(r"^(?P<stable>\d+\.\d+\.\d+)-(?:rc|alpha|beta)\.\d+$")
PRERELEASE_RE = re.compile(r"^(?P<stable>\d+\.\d+\.\d+)-(?:[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)$")
TAG_LITERAL_RE = re.compile(r"`(v\d+\.\d+\.\d+-rc\.\d+)`")
ACCIDENTAL_RC_TAG_DECISION_ID = "accidental-prerelease-tags-do-not-count-as-shipped-rcs"
RELEASE_DRY_RUN_WORKFLOW = ".github/workflows/release-dry-run.yml"

View file

@ -241,7 +241,7 @@ class ReleasePromotionPolicyTest(unittest.TestCase):
def test_blocked_record_tracks_current_target_and_candidate_version(self) -> None:
blocked = read("docs/release-control/v6/internal/records/rc-to-ga-promotion-readiness-blocked-2026-04-04.md")
self.assertIn("origin/pulse/v6-release", blocked)
self.assertIn("VERSION=6.0.0-rc.1", blocked)
self.assertIn("VERSION=6.0.0-dev", blocked)
self.assertIn("artifact-owned candidate stable tag", blocked)
self.assertIn("artifact-owned promotion channel", blocked)
self.assertIn("artifact-owned promoted prerelease tag", blocked)

View file

@ -13,7 +13,7 @@ from typing import Callable
from repo_file_io import REPO_ROOT, git_env
SEMVER_PRERELEASE_RE = re.compile(r"-(?:rc|alpha|beta)\.\d+$")
SEMVER_PRERELEASE_RE = re.compile(r"-(?:[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)(?:\+[0-9A-Za-z.-]+)?$")
def normalize_tag(value: str) -> str:

View file

@ -9,6 +9,22 @@ import resolve_release_promotion as resolver
class ResolveReleasePromotionTest(unittest.TestCase):
def test_dev_prerelease_uses_prerelease_path(self) -> None:
metadata = resolver.resolve_metadata(
version="6.0.0-dev",
promoted_from_tag_input="",
rollback_version_input="5.1.14",
ga_date_input="",
v5_eos_date_input="",
hotfix_exception=False,
hotfix_reason_input="",
release_notes_input="",
tag_exists_fn=lambda tag: tag == "v5.1.14",
)
self.assertEqual(metadata["rollback_tag"], "v5.1.14")
self.assertEqual(metadata["promoted_from_tag"], "")
self.assertEqual(metadata["soak_hours"], "")
def test_prerelease_requires_explicit_stable_rollback(self) -> None:
metadata = resolver.resolve_metadata(
version="6.0.0-rc.2",