Map legacy stable releases in control plane

This commit is contained in:
rcourtman 2026-04-11 13:23:07 +01:00
parent 5e7c6d4c7b
commit 1a0acd310a
6 changed files with 151 additions and 3 deletions

View file

@ -7,6 +7,13 @@
"control_plane_schema": "docs/release-control/control_plane.schema.json",
"active_profile_id": "v6",
"active_target_id": "v6-rc-stabilization",
"legacy_release_lines": [
{
"version_prefix": "5.1.",
"prerelease_branch": "main",
"stable_branch": "main"
}
],
"profiles": [
{
"id": "v6",

View file

@ -49,6 +49,12 @@
"type": "string",
"minLength": 1
},
"legacy_release_lines": {
"type": "array",
"items": {
"$ref": "#/$defs/legacy_release_line"
}
},
"profiles": {
"type": "array",
"minItems": 1,
@ -65,6 +71,29 @@
}
},
"$defs": {
"legacy_release_line": {
"type": "object",
"additionalProperties": false,
"required": [
"version_prefix",
"prerelease_branch",
"stable_branch"
],
"properties": {
"version_prefix": {
"type": "string",
"minLength": 1
},
"prerelease_branch": {
"type": "string",
"minLength": 1
},
"stable_branch": {
"type": "string",
"minLength": 1
}
}
},
"profile": {
"type": "object",
"additionalProperties": false,

View file

@ -32,6 +32,10 @@ model that active and future release profiles reuse.
The active profile also owns the governed prerelease and stable release
branches for that line, so workflows and local release tooling must resolve
branch requirements from the control plane instead of hard-coding `main`.
When an older maintenance line still needs governed release or demo-routing
support after the active profile has moved on, that branch mapping must stay
explicit in `control_plane.json` as a legacy release-line override rather
than being inferred from the active profile or smuggled into one workflow.
4. Profile changes must reuse the same control-plane machinery.
Future releases should switch or add profiles rather than fork the guardrail
system.
@ -250,10 +254,16 @@ user language should update the control plane.
long-term system.
8. Until the explicit post-GA branch cutover happens, both prerelease and
stable v6 promotions resolve to `pulse/v6-release` via `control_plane.json`.
9. `AGENT_VALUES.md` is the evergreen values-only entry point; prompts should
9. Legacy maintenance releases that still feed governed automation outside the
active v6 line must also resolve through `control_plane.json`.
Right now the remaining `5.1.x` stable maintenance line resolves to `main`
via an explicit legacy release-line override, so stable demo automation can
validate the real shipped stable tag without pretending that v5 tags belong
to `pulse/v6-release`.
10. `AGENT_VALUES.md` is the evergreen values-only entry point; prompts should
stay close to that layer and delegate detailed behavior to the governed
control-plane and profile-specific files.
10. `python3 scripts/release_control/control_plane.py --agent-entrypoint --pretty`
11. `python3 scripts/release_control/control_plane.py --agent-entrypoint --pretty`
is the executable entrypoint for that ordered guidance bundle; agents
should prefer it over reconstructing the bundle ad hoc.
That entrypoint should lead with lightweight startup files plus derived

View file

@ -203,7 +203,10 @@ preview paths in menus, CLI help text, operator diagnostics, and runtime logs
rather than as release-candidate promises.
Those same workflows must also fetch and dispatch the governed release branch
derived from release-control metadata instead of hardcoding `pulse/v6`,
`pulse/v6-release`, or any later branch literal inline.
`pulse/v6-release`, `main`, or any later branch literal inline; when a stable
maintenance line such as `5.1.x` remains live after the active profile has
moved on, that branch routing must come from an explicit control-plane release
line override instead of being guessed inside the workflow.
That same branch-policy contract must survive step boundaries inside the
workflows themselves: `.github/workflows/create-release.yml` and
`.github/workflows/release-dry-run.yml` must pass the resolved

View file

@ -30,6 +30,11 @@ REQUIRED_CONTROL_PLANE_FIELDS = (
"profiles",
"targets",
)
REQUIRED_LEGACY_RELEASE_LINE_FIELDS = (
"version_prefix",
"prerelease_branch",
"stable_branch",
)
REQUIRED_PROFILE_FIELDS = (
"id",
"lifecycle",
@ -145,6 +150,40 @@ def validate_control_plane_payload(payload: dict[str, Any]) -> dict[str, Any]:
if not isinstance(active_target_id, str) or not active_target_id.strip():
raise ValueError("control plane active_target_id must be a non-empty string")
legacy_release_lines: list[dict[str, str]] = []
raw_legacy_release_lines = payload.get("legacy_release_lines", [])
if not isinstance(raw_legacy_release_lines, list):
raise ValueError("control plane legacy_release_lines must be a list when declared")
seen_version_prefixes: set[str] = set()
for index, raw in enumerate(raw_legacy_release_lines):
if not isinstance(raw, dict):
raise ValueError(f"control plane legacy_release_lines[{index}] must be an object")
line: dict[str, str] = {}
for field in REQUIRED_LEGACY_RELEASE_LINE_FIELDS:
if field not in raw:
raise ValueError(f"control plane legacy_release_lines[{index}] missing {field!r}")
value = raw[field]
if not isinstance(value, str) or not value.strip():
raise ValueError(
f"control plane legacy_release_lines[{index}].{field} must be a non-empty string"
)
line[field] = str(value)
version_prefix = line["version_prefix"]
if version_prefix in seen_version_prefixes:
raise ValueError(
f"control plane legacy_release_lines duplicates version_prefix {version_prefix!r}"
)
seen_version_prefixes.add(version_prefix)
_clean_branch_name(
line["prerelease_branch"],
context=f"legacy_release_line {version_prefix}.prerelease_branch",
)
_clean_branch_name(
line["stable_branch"],
context=f"legacy_release_line {version_prefix}.stable_branch",
)
legacy_release_lines.append(line)
raw_profiles = payload.get("profiles")
if not isinstance(raw_profiles, list) or not raw_profiles:
raise ValueError("control plane profiles must be a non-empty list")
@ -261,6 +300,7 @@ def validate_control_plane_payload(payload: dict[str, Any]) -> dict[str, Any]:
"agent_values_doc_path": REPO_ROOT / AGENT_VALUES_DOC_REL,
"control_plane_doc_path": REPO_ROOT / CONTROL_PLANE_DOC_REL,
"control_plane_schema_path": REPO_ROOT / CONTROL_PLANE_SCHEMA_REL,
"legacy_release_lines": legacy_release_lines,
"profiles": profiles,
"profiles_by_id": profiles_by_id,
"targets": targets,
@ -383,6 +423,30 @@ def is_prerelease_version(version: str) -> bool:
return bool(PRERELEASE_VERSION_PATTERN.search(version))
def legacy_release_line_for_version(
version: str,
*,
control_plane: dict[str, Any] | None = None,
staged: bool = False,
) -> dict[str, str] | None:
resolved = control_plane
if resolved is None:
resolved = active_control_plane(staged=staged)
elif "profiles_by_id" not in resolved:
resolved = validate_control_plane_payload(control_plane)
normalized_version = version[1:] if version.startswith("v") else version
legacy_release_lines = sorted(
resolved.get("legacy_release_lines", []),
key=lambda line: len(line["version_prefix"]),
reverse=True,
)
for line in legacy_release_lines:
if normalized_version.startswith(line["version_prefix"]):
return line
return None
def release_branch_for_version(
version: str,
*,
@ -394,6 +458,13 @@ def release_branch_for_version(
resolved = active_control_plane(staged=staged)
elif "profiles_by_id" not in resolved:
resolved = validate_control_plane_payload(control_plane)
legacy_release_line = legacy_release_line_for_version(version, control_plane=resolved)
if legacy_release_line is not None:
return (
legacy_release_line["prerelease_branch"]
if is_prerelease_version(version)
else legacy_release_line["stable_branch"]
)
return resolved["prerelease_branch"] if is_prerelease_version(version) else resolved["stable_branch"]

View file

@ -13,6 +13,13 @@ VALID_PAYLOAD = {
"control_plane_schema": "docs/release-control/control_plane.schema.json",
"active_profile_id": "v6",
"active_target_id": "v6-rc-cut",
"legacy_release_lines": [
{
"version_prefix": "5.1.",
"prerelease_branch": "main",
"stable_branch": "main",
}
],
"profiles": [
{
"id": "v6",
@ -167,10 +174,31 @@ class ControlPlaneAuditTest(unittest.TestCase):
self.assertFalse(report["summary"]["active_target_completion_met"])
def test_release_branch_for_version_uses_profile_branch_policy(self) -> None:
self.assertEqual(release_branch_for_version("5.1.27", control_plane=VALID_PAYLOAD), "main")
self.assertEqual(release_branch_for_version("5.1.27-rc.3", control_plane=VALID_PAYLOAD), "main")
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_release_branch_for_version_prefers_longest_legacy_prefix(self) -> None:
payload = dict(VALID_PAYLOAD)
payload["legacy_release_lines"] = [
{
"version_prefix": "5.",
"prerelease_branch": "main",
"stable_branch": "main",
},
{
"version_prefix": "5.1.",
"prerelease_branch": "pulse/release-5.1.25",
"stable_branch": "pulse/release-5.1.25",
},
]
self.assertEqual(
release_branch_for_version("5.1.27", control_plane=payload),
"pulse/release-5.1.25",
)
def test_audit_flags_stale_active_target(self) -> None:
report = audit_control_plane_payload(
VALID_PAYLOAD,