diff --git a/VERSION b/VERSION index c3a4d6bbb..420dc8d24 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -6.0.0-rc.1 +6.0.0-dev diff --git a/docs/release-control/v6/internal/records/rc-to-ga-promotion-readiness-blocked-2026-04-04.md b/docs/release-control/v6/internal/records/rc-to-ga-promotion-readiness-blocked-2026-04-04.md index d1a1dd48c..40c259ff5 100644 --- a/docs/release-control/v6/internal/records/rc-to-ga-promotion-readiness-blocked-2026-04-04.md +++ b/docs/release-control/v6/internal/records/rc-to-ga-promotion-readiness-blocked-2026-04-04.md @@ -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. diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index d8fae9a77..73569fbaa 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index aa731c622..43b37da58 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index b43d67998..8e55f0105 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -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`, diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index a64222df9..a25a93340 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -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 diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index 2322c6704..61ab1025b 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -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/", diff --git a/internal/api/unified_agent.go b/internal/api/unified_agent.go index 4b1239eb2..11a77086a 100644 --- a/internal/api/unified_agent.go +++ b/internal/api/unified_agent.go @@ -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( diff --git a/internal/api/unified_agent_more_test.go b/internal/api/unified_agent_more_test.go index b7a5c78cc..f75fe485b 100644 --- a/internal/api/unified_agent_more_test.go +++ b/internal/api/unified_agent_more_test.go @@ -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" diff --git a/internal/updates/version.go b/internal/updates/version.go index 5dc0d9456..019cb41d0 100644 --- a/internal/updates/version.go +++ b/internal/updates/version.go @@ -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 diff --git a/internal/updates/version_additional_test.go b/internal/updates/version_additional_test.go index 3aff49bbc..ca74f80e3 100644 --- a/internal/updates/version_additional_test.go +++ b/internal/updates/version_additional_test.go @@ -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" { diff --git a/internal/updates/version_test.go b/internal/updates/version_test.go index 0d4928a57..0df5b0f24 100644 --- a/internal/updates/version_test.go +++ b/internal/updates/version_test.go @@ -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() diff --git a/scripts/release_control/control_plane.py b/scripts/release_control/control_plane.py index 7be1fc6a8..add6138d6 100644 --- a/scripts/release_control/control_plane.py +++ b/scripts/release_control/control_plane.py @@ -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"), diff --git a/scripts/release_control/control_plane_audit_test.py b/scripts/release_control/control_plane_audit_test.py index 45d28b73c..9f03df399 100644 --- a/scripts/release_control/control_plane_audit_test.py +++ b/scripts/release_control/control_plane_audit_test.py @@ -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: diff --git a/scripts/release_control/internal/resolve_release_promotion.py b/scripts/release_control/internal/resolve_release_promotion.py index a902ea0c7..cf2c683d6 100644 --- a/scripts/release_control/internal/resolve_release_promotion.py +++ b/scripts/release_control/internal/resolve_release_promotion.py @@ -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: diff --git a/scripts/release_control/internal/resolve_release_promotion_test.py b/scripts/release_control/internal/resolve_release_promotion_test.py index e3ed24b32..130d6de2a 100644 --- a/scripts/release_control/internal/resolve_release_promotion_test.py +++ b/scripts/release_control/internal/resolve_release_promotion_test.py @@ -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", diff --git a/scripts/release_control/record_rc_to_ga_blocked.py b/scripts/release_control/record_rc_to_ga_blocked.py index 4f7ca061f..edf28125b 100644 --- a/scripts/release_control/record_rc_to_ga_blocked.py +++ b/scripts/release_control/record_rc_to_ga_blocked.py @@ -20,7 +20,7 @@ GA_DATE_RE = re.compile( V5_EOS_RE = re.compile( r"existing v5 users until `(?P\[v5-eos-date\]|\d{4}-\d{2}-\d{2})`\." ) -PRERELEASE_RE = re.compile(r"^(?P\d+\.\d+\.\d+)-(?:rc|alpha|beta)\.\d+$") +PRERELEASE_RE = re.compile(r"^(?P\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" diff --git a/scripts/release_control/release_promotion_policy_test.py b/scripts/release_control/release_promotion_policy_test.py index 6cdb989c5..f32dba5eb 100644 --- a/scripts/release_control/release_promotion_policy_test.py +++ b/scripts/release_control/release_promotion_policy_test.py @@ -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) diff --git a/scripts/release_control/resolve_release_promotion.py b/scripts/release_control/resolve_release_promotion.py index deb857180..1d4adb576 100644 --- a/scripts/release_control/resolve_release_promotion.py +++ b/scripts/release_control/resolve_release_promotion.py @@ -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: diff --git a/scripts/release_control/resolve_release_promotion_test.py b/scripts/release_control/resolve_release_promotion_test.py index 1ed8f1aac..0f99dead7 100644 --- a/scripts/release_control/resolve_release_promotion_test.py +++ b/scripts/release_control/resolve_release_promotion_test.py @@ -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",