Skip to content

Commit e3a1707

Browse files
committed
Support deploying Modal release to both channels
1 parent 4a85046 commit e3a1707

8 files changed

Lines changed: 122 additions & 1 deletion

File tree

.github/scripts/test_resolve_modal_release_config.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,27 @@ def test_resolve_release_uses_workflow_dispatch_inputs():
6262
assert resolved.config.cleanup_target == "frontier"
6363

6464

65+
def test_resolve_release_accepts_both_workflow_dispatch_target():
66+
resolved = resolve_release_from_event(
67+
{
68+
"inputs": {
69+
"new_app_target": "both",
70+
"promote_existing_frontier": "false",
71+
"cleanup_target": "retired",
72+
}
73+
},
74+
fetch_pr_body_for_commit=lambda _repository, _sha: None,
75+
event_name="workflow_dispatch",
76+
)
77+
78+
assert resolved.should_deploy is True
79+
assert resolved.deploy_mode == "release"
80+
assert resolved.config is not None
81+
assert resolved.config.new_app_target == "both"
82+
assert resolved.config.promote_existing_frontier is False
83+
assert resolved.config.cleanup_target == "retired"
84+
85+
6586
def test_resolve_release_uses_weekly_default_for_empty_workflow_dispatch():
6687
resolved = resolve_release_from_event(
6788
{"inputs": {}},

.github/workflows/deploy-staged.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ on:
1414
options:
1515
- frontier
1616
- current
17+
- both
1718
- none
1819
promote_existing_frontier:
1920
description: "Move the existing frontier worker to current before deploying a new frontier"

changelog.d/1533.changed.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Allow Modal releases to launch the same newly built worker on both `current` and `frontier`.

docs/engineering/skills/modal-release-prs.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ modal_release:
2424
2525
Allowed values:
2626
27-
- `new_app_target`: `frontier`, `current`, or `none`
27+
- `new_app_target`: `frontier`, `current`, `both`, or `none`
2828
- `promote_existing_frontier`: `true` or `false`
2929
- `cleanup_target`: `none`, `retired`, `frontier`, or `current`
3030

@@ -50,6 +50,20 @@ apps in the retired history are stopped after the manifest is updated. Use
5050
`cleanup_target: none` only when the user explicitly asks to preserve retired
5151
worker apps after release.
5252

53+
Use this release shape when the newly built worker must become both `current`
54+
and `frontier` in a single release:
55+
56+
```yaml
57+
modal_release:
58+
new_app_target: both
59+
promote_existing_frontier: false
60+
cleanup_target: retired
61+
```
62+
63+
This deploys one new worker app, writes the same app reference into both
64+
`current` and `frontier`, and moves the previous active workers into the
65+
manifest's `retired` history.
66+
5367
Do not use PR labels, branch names, model-specific tags, or title prefixes to
5468
control Modal release behavior. The PR body YAML block is the source of truth.
5569
The PR-body YAML block is the only automatic signal that a deployment should

policyengine_household_api/modal_release/manifest.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,22 @@ def apply_release_config(
207207
next_manifest["current"] = deepcopy(dict(new_app or {}))
208208
next_manifest["frontier"] = None
209209

210+
elif config.new_app_target == NewAppTarget.BOTH:
211+
retired = _retire_entry(
212+
retired,
213+
next_manifest.get("current"),
214+
retired_at=retired_at,
215+
reason="replaced-current",
216+
)
217+
retired = _retire_entry(
218+
retired,
219+
next_manifest.get("frontier"),
220+
retired_at=retired_at,
221+
reason="replaced-frontier",
222+
)
223+
next_manifest["current"] = deepcopy(dict(new_app or {}))
224+
next_manifest["frontier"] = deepcopy(dict(new_app or {}))
225+
210226
next_manifest["retired"] = retired
211227
return validate_manifest(next_manifest)
212228

policyengine_household_api/modal_release/release_config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ class ModalReleaseConfigError(ValueError):
3838
class NewAppTarget(StrEnum):
3939
FRONTIER = "frontier"
4040
CURRENT = "current"
41+
BOTH = "both"
4142
NONE = "none"
4243

4344

tests/unit/modal_release/test_manifest.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,43 @@ def test_direct_current_release_retires_current_and_frontier():
8484
]
8585

8686

87+
def test_both_release_sets_current_and_frontier_to_new_app():
88+
manifest = {
89+
"schema_version": 1,
90+
"current": _app("current-app", us="1.690.0"),
91+
"frontier": _app("frontier-app"),
92+
"retired": [],
93+
}
94+
config = ModalReleaseConfig(
95+
new_app_target=NewAppTarget.BOTH,
96+
promote_existing_frontier=False,
97+
cleanup_target=CleanupTarget.RETIRED,
98+
)
99+
new_app = _app("new-shared-app", uk="2.88.18")
100+
101+
updated = apply_release_config(
102+
manifest,
103+
config,
104+
new_app=new_app,
105+
timestamp="2026-01-02T00:00:00+00:00",
106+
)
107+
108+
assert updated["current"] == new_app
109+
assert updated["frontier"] == new_app
110+
assert [app["app_name"] for app in updated["retired"]] == [
111+
"current-app",
112+
"frontier-app",
113+
]
114+
assert [app["retirement_reason"] for app in updated["retired"]] == [
115+
"replaced-current",
116+
"replaced-frontier",
117+
]
118+
assert cleanup_app_names_for_target(
119+
updated,
120+
CleanupTarget.RETIRED,
121+
) == ["current-app", "frontier-app"]
122+
123+
87124
def test_retired_cleanup_excludes_active_apps():
88125
manifest = {
89126
"schema_version": 1,

tests/unit/modal_release/test_release_config.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,22 @@ def test_parse_modal_release_config_from_fenced_yaml():
2929
assert config.promote_existing_frontier is True
3030

3131

32+
def test_parse_accepts_both_target_without_promotion():
33+
config = parse_modal_release_config_from_body(
34+
"""
35+
```yaml
36+
modal_release:
37+
new_app_target: both
38+
promote_existing_frontier: false
39+
cleanup_target: retired
40+
```
41+
"""
42+
)
43+
44+
assert config.new_app_target == NewAppTarget.BOTH
45+
assert config.promote_existing_frontier is False
46+
47+
3248
def test_parse_rejects_missing_config():
3349
with pytest.raises(ModalReleaseConfigError, match="modal_release"):
3450
parse_modal_release_config_from_body("## Summary\n")
@@ -58,6 +74,20 @@ def test_parse_rejects_invalid_promotion_combination():
5874
)
5975

6076

77+
def test_parse_rejects_both_target_with_promotion():
78+
with pytest.raises(ModalReleaseConfigError, match="may only be true"):
79+
parse_modal_release_config_from_body(
80+
"""
81+
```yaml
82+
modal_release:
83+
new_app_target: both
84+
promote_existing_frontier: true
85+
cleanup_target: none
86+
```
87+
"""
88+
)
89+
90+
6191
def test_parse_rejects_active_cleanup_in_pr_config():
6292
with pytest.raises(ModalReleaseConfigError, match="may not be"):
6393
parse_modal_release_config_from_body(

0 commit comments

Comments
 (0)