[ENV-LADDER] Add per-project ordered environment ladder column + shim

Replace the single default_branch with an ordered environments ladder
(list[{name,branch}]; first=head=PR target, last=prod=release target,
middle=intermediates). Migration 073 adds nullable JSONB projects.environments;
a read-time shim (roboco/models/env_branches.py) synthesizes a degenerate
single-branch ladder from default_branch when environments is null, so
behavior is unchanged until the operator declares a real split in the panel.
Wired through Project/ProjectCreate/ProjectUpdate models (normalize_environments
validator: rejects empty name/branch + dup branches), ProjectTable column,
API request/response schemas, create+update routes + service. EnvSyncEngine
+ consumer rewires follow.
This commit is contained in:
Renn F
2026-07-15 02:46:40 +02:00
parent 3338f88e1b
commit 8621d01d54
7 changed files with 213 additions and 0 deletions
@@ -0,0 +1,42 @@
"""Per-project ordered environment ladder column.
Replaces the single ``default_branch`` as the source of truth for a project's
PR target (head rung) and release target (prod rung). The ladder is an ordered
``list[{name, branch}]``: index 0 = head (where dev/cell/leaf PRs land), index
-1 = prod (where the gated release executor commits + tags), middle rungs =
intermediates (qa/stag). The ``EnvSyncEngine`` cascades prod→…→head so dev
never falls behind prod, and the CEO-gated release promotes the full chain
head→…→prod before bumping.
Additive and nullable: an unset (null) ``environments`` falls back to a
degenerate single-branch ladder synthesized from ``default_branch`` at read
time (``roboco/services/env_branches.py``), so every existing project keeps
behaving byte-for-byte as before until the operator declares a real ladder
in the panel. ``default_branch`` is retained as the legacy/shim source.
Revision ID: 073_project_environments
Revises: 072_project_sandbox_extensions
Create Date: 2026-07-15
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision = "073_project_environments"
down_revision = "072_project_sandbox_extensions"
branch_labels: dict[str, str] | None = None
depends_on: dict[str, str] | None = None
def upgrade() -> None:
op.add_column(
"projects",
sa.Column("environments", postgresql.JSONB(), nullable=True),
)
def downgrade() -> None:
op.drop_column("projects", "environments")
+1
View File
@@ -141,6 +141,7 @@ async def create_project(
git_url=data.git_url,
default_branch=data.default_branch,
protected_branches=protected_branches,
environments=data.environments,
assigned_cell=data.assigned_cell,
git_token=data.git_token,
test_command=data.test_command,
+12
View File
@@ -31,6 +31,7 @@ class ProjectResponse(BaseModel):
git_url: str
default_branch: str
protected_branches: list[str]
environments: list[dict[str, str]] | None = None
assigned_cell: Team
# Git authentication status (token never exposed, only boolean)
@@ -105,6 +106,14 @@ class ProjectCreateRequest(BaseModel):
default=None,
description="Branches to protect. Defaults to [default_branch].",
)
environments: list[dict[str, str]] | None = Field(
default=None,
description=(
"Ordered environment ladder [{name, branch}]; first = head (PR "
"target), last = prod (release target). null → inherits "
"default_branch (head == prod)."
),
)
assigned_cell: Team
# Git authentication (will be encrypted and stored securely)
@@ -129,6 +138,7 @@ class ProjectUpdateRequest(BaseModel):
git_url: str | None = None
default_branch: str | None = None
protected_branches: list[str] | None = None
environments: list[dict[str, str]] | None = None
assigned_cell: Team | None = None
# Git authentication (empty string clears token, None leaves unchanged)
@@ -226,6 +236,7 @@ def project_to_response(project: "ProjectTable") -> ProjectResponse:
git_url=str(project.git_url),
default_branch=str(default_branch) if default_branch else "master",
protected_branches=list(project.protected_branches or []),
environments=list(project.environments) if project.environments else None,
assigned_cell=project.assigned_cell,
has_git_token=bool(project.git_token_encrypted),
test_command=project.test_command,
@@ -266,3 +277,4 @@ def project_to_summary(project: "ProjectTable") -> ProjectSummaryResponse:
has_git_token=bool(project.git_token_encrypted),
video_engine_enabled=bool(project.video_engine_enabled),
)
+8
View File
@@ -498,6 +498,14 @@ class ProjectTable(Base):
protected_branches: Mapped[list[str]] = mapped_column(
ARRAY(String), default=lambda: ["main", "master"]
)
# Ordered environment ladder [{name, branch}]: index 0 = head (PR target),
# index -1 = prod (release target), middle = intermediates. Null →
# roboco.services.env_branches synthesizes a degenerate single-branch
# ladder from default_branch (head == prod == default_branch), so behavior
# is unchanged until the operator declares a real split.
environments: Mapped[list[dict[str, Any]] | None] = mapped_column(
JSONB, nullable=True
)
git_token_encrypted: Mapped[str | None] = mapped_column(
Text, nullable=True
) # Fernet-encrypted GitHub PAT
+119
View File
@@ -0,0 +1,119 @@
"""Per-project environment ladder helpers.
A project's environment ladder is an ordered ``list[{name, branch}]``: index 0
is **head** (the branch dev/cell/leaf PRs target the dev trunk), index -1 is
**prod** (the branch the gated release executor commits + tags on), and the
middle rungs are intermediates (qa/stag). This separates "where work lands"
from "what prod is", which a single ``default_branch`` could not.
When ``environments`` is null/empty the ladder is synthesized from
``default_branch`` as a degenerate single-branch ladder (head == prod ==
``default_branch``), so every consumer PR target, release target, sync
keeps behaving exactly as before the column existed. Operators declare a real
split in the panel.
These helpers are the single chokepoint every consumer routes through; never
read ``project.environments`` raw. Pure domain logic (pydantic-only), kept in
``roboco.models`` so the Project model can import it without a services-cycle.
"""
from __future__ import annotations
from pydantic import BaseModel
# A project or project-row duck-type: both the pydantic ``Project`` and the
# SQLAlchemy ``ProjectTable`` expose ``environments`` and ``default_branch``.
_ENV_LADDER = "head"
_PROD_LADDER = "prod"
class EnvRung(BaseModel):
"""One rung of the environment ladder."""
name: str
branch: str
def _coerce_rungs(
raw: list[dict[str, str]] | list[EnvRung] | None,
) -> list[EnvRung] | None:
"""Normalize a raw list of dicts/models into EnvRungs; None/empty -> None."""
if not raw:
return None
out: list[EnvRung] = []
for item in raw:
if isinstance(item, EnvRung):
out.append(item)
else:
out.append(EnvRung.model_validate(item))
return out
def effective_environments(project: object) -> list[EnvRung]:
"""Return the project's resolved environment ladder (never empty).
Falls back to a degenerate single-branch ladder synthesized from
``default_branch`` when ``environments`` is null/empty, so behavior is
unchanged until the operator declares a real ladder.
"""
rungs = _coerce_rungs(getattr(project, "environments", None)) # type: ignore[arg-type]
if rungs:
return rungs
fallback = str(getattr(project, "default_branch", None) or "master")
return [
EnvRung(name=_ENV_LADDER, branch=fallback),
EnvRung(name=_PROD_LADDER, branch=fallback),
]
def head_branch(project: object) -> str:
"""The branch dev/cell/leaf PRs target (ladder index 0)."""
return effective_environments(project)[0].branch
def prod_branch(project: object) -> str:
"""The branch the gated release executor commits + tags on (last rung)."""
return effective_environments(project)[-1].branch
def ladder_pairs(project: object) -> list[tuple[EnvRung, EnvRung]]:
"""Adjacent rung pairs for the prod->head cascade, top-down.
For ``[head, qa, stag, prod]`` (index 0..3) returns
``[(prod, stag), (stag, qa), (qa, head)]`` each ``(upper, lower)`` pair
means "merge ``upper`` into ``lower``". Empty for a 1-rung ladder.
"""
rungs = effective_environments(project)
return [(rungs[i], rungs[i - 1]) for i in range(len(rungs) - 1, 0, -1)]
def normalize_environments(
value: list[dict[str, str]] | list[EnvRung] | None,
) -> list[dict[str, str]] | None:
"""Validate + normalize a ladder before persistence.
Rejects empty name/branch, de-dupes by branch, preserves the declared
order. Returns None for None/empty so the column stays null (the shim
synthesizes the degenerate ladder from ``default_branch`` at read time).
"""
rungs = _coerce_rungs(value)
if not rungs:
return None
seen: set[str] = set()
out: list[dict[str, str]] = []
for rung in rungs:
name = rung.name.strip()
branch = rung.branch.strip()
if not name or not branch:
raise ValueError(
"each environment rung needs a non-empty name and branch"
)
if branch in seen:
raise ValueError(
f"duplicate environment branch {branch!r}; "
"each rung must target a distinct branch"
)
seen.add(branch)
out.append({"name": name, "branch": branch})
return out
+30
View File
@@ -13,6 +13,7 @@ from uuid import UUID, uuid4
from pydantic import Field, field_validator
from roboco.models.base import RobocoBase, Team, TimestampMixin
from roboco.models.env_branches import normalize_environments
from roboco.models.sandbox import (
SANDBOX_ENGINE_FEATURES,
SANDBOX_ENGINES,
@@ -119,6 +120,26 @@ class Project(TimestampMixin):
default_factory=lambda: ["main", "master"],
description="Branches that cannot be pushed to directly",
)
# Ordered environment ladder: index 0 = head (PR target / dev trunk),
# index -1 = prod (release target), middle = intermediates (qa/stag).
# null/empty → synthesized from default_branch as a degenerate single-
# branch ladder (head == prod == default_branch), so behavior is unchanged
# until the operator declares a real split in the panel.
environments: list[dict[str, str]] | None = Field(
default=None,
description=(
"Ordered environment ladder [{name, branch}]; first = head (PR "
"target), last = prod (release target). null → inherits "
"default_branch (head == prod). Validated by _check_environments."
),
)
@field_validator("environments")
@classmethod
def _check_environments(
cls, v: list[dict[str, str]] | None
) -> list[dict[str, str]] | None:
return normalize_environments(v)
# CI/CD Commands (optional - project may not have all)
test_command: str | None = Field(
@@ -239,6 +260,7 @@ class ProjectCreate(RobocoBase):
git_url: str
default_branch: str = "master"
protected_branches: list[str] = Field(default_factory=lambda: ["main", "master"])
environments: list[dict[str, str]] | None = None
assigned_cell: Team
# Git authentication (will be encrypted and stored securely)
@@ -263,6 +285,7 @@ class ProjectUpdate(RobocoBase):
git_url: str | None = None
default_branch: str | None = None
protected_branches: list[str] | None = None
environments: list[dict[str, str]] | None = None
# Git authentication (empty string clears token, None leaves unchanged)
git_token: str | None = Field(
@@ -298,3 +321,10 @@ class ProjectUpdate(RobocoBase):
cls, v: dict[str, list[str]] | None
) -> dict[str, list[str]] | None:
return _normalize_sandbox_extensions(v)
@field_validator("environments")
@classmethod
def _check_environments(
cls, v: list[dict[str, str]] | None
) -> list[dict[str, str]] | None:
return normalize_environments(v)
+1
View File
@@ -96,6 +96,7 @@ class ProjectService(BaseService):
git_url=data.git_url,
default_branch=data.default_branch,
protected_branches=data.protected_branches,
environments=data.environments,
assigned_cell=data.assigned_cell,
git_token_encrypted=encrypted_token,
test_command=data.test_command,