Skip to content

Commit b2f9793

Browse files
committed
fix!: check for base branch when deploying to prod
1 parent d5c3ca7 commit b2f9793

File tree

7 files changed

+213
-12
lines changed

7 files changed

+213
-12
lines changed

docs/integrations/github.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ In this example we configured the merge method to be `squash`. See [Bot Configur
113113

114114
One way to signal to SQLMesh that a PR is ready to go to production is through the use of "Required Approvers".
115115
In this approach users configure their SQLMesh project to list users that are designated as "Required Approver" and then when the bot detects an approval was received from one of these individuals then it determines that it is time to deploy to production.
116+
The bot will only do the deploy to prod if the base branch is a production branch (as defined in the bot's configuration but defaults to either `main` or `master`).
116117
This pattern can be a great fit for teams that already have an approval process like this in place and therefore it actually removes an extra step from either the author or the approver since SQLMesh will automate the deployment and merge until of it having to be manually done.
117118

118119
##### Required Approval Configuration
@@ -295,8 +296,9 @@ Below is an example of how to define the default config for the bot in either YA
295296
| `default_pr_start` | Default start when creating PR environment plans. If running in a mode where the bot automatically backfills models (based on `auto_categorize_changes` behavior) then this can be used to limit the amount of data backfilled. Defaults to `None` meaning the start date is set to the earliest model's start or to 1 day ago if [data previews](../concepts/plans.md#data-preview) need to be computed. | str | N |
296297
| `skip_pr_backfill` | Indicates if the bot should skip backfilling models in the PR environment. Default: `True` | bool | N |
297298
| `pr_include_unmodified` | Indicates whether to include unmodified models in the PR environment. Default to the project's config value (which defaults to `False`) | bool | N |
298-
| `run_on_deploy_to_prod` | Indicates whether to run latest intervals when deploying to prod. If set to false, the deployment will backfill only the changed models up to the existing latest interval in production, ignoring any missing intervals beyond this point. Default: `False` | bool | N |
299-
| `pr_environment_name` | The name of the PR environment to create for which a PR number will be appended to. Defaults to the repo name if not provided. Note: The name will be normalized to alphanumeric + underscore and lowercase. | str | N |
299+
| `run_on_deploy_to_prod` | Indicates whether to run latest intervals when deploying to prod. If set to false, the deployment will backfill only the changed models up to the existing latest interval in production, ignoring any missing intervals beyond this point. Default: `False` | bool | N |
300+
| `pr_environment_name` | The name of the PR environment to create for which a PR number will be appended to. Defaults to the repo name if not provided. Note: The name will be normalized to alphanumeric + underscore and lowercase. | str | N |
301+
| `prod_branch_name` | The name of the git branch associated with production. Ex: "prod". Default: "main" or "master" is considered prod | str | N |
300302

301303
Example with all properties defined:
302304

@@ -317,6 +319,7 @@ Example with all properties defined:
317319
default_pr_start: "1 week ago"
318320
skip_pr_backfill: false
319321
run_on_deploy_to_prod: false
322+
prod_branch_name: production
320323
```
321324

322325
=== "Python"
@@ -340,6 +343,7 @@ Example with all properties defined:
340343
default_pr_start="1 week ago",
341344
skip_pr_backfill=False,
342345
run_on_deploy_to_prod=False,
346+
prod_branch_name="production",
343347
)
344348
)
345349
```

sqlmesh/integrations/github/cicd/command.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,7 @@ def _run_all(controller: GithubController) -> None:
210210
has_required_approval = False
211211
is_auto_deploying_prod = (
212212
controller.deploy_command_enabled or controller.do_required_approval_check
213-
)
213+
) and controller.pr_targets_prod_branch
214214
if controller.is_comment_added:
215215
if not controller.deploy_command_enabled:
216216
# We aren't using commands so we can just return
@@ -267,7 +267,7 @@ def _run_all(controller: GithubController) -> None:
267267
status=GithubCheckStatus.COMPLETED, conclusion=GithubCheckConclusion.SKIPPED
268268
)
269269
deployed_to_prod = False
270-
if has_required_approval and prod_plan_generated:
270+
if has_required_approval and prod_plan_generated and controller.pr_targets_prod_branch:
271271
deployed_to_prod = _deploy_production(controller)
272272
elif is_auto_deploying_prod:
273273
if not has_required_approval:
@@ -292,7 +292,7 @@ def _run_all(controller: GithubController) -> None:
292292
if (
293293
not pr_environment_updated
294294
or not prod_plan_generated
295-
or (has_required_approval and not deployed_to_prod)
295+
or (has_required_approval and controller.pr_targets_prod_branch and not deployed_to_prod)
296296
):
297297
raise CICDBotError(
298298
"A step of the run-all check failed. See check status for more information."

sqlmesh/integrations/github/cicd/config.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ class GithubCICDBotConfig(BaseConfig):
2828
pr_include_unmodified: t.Optional[bool] = None
2929
run_on_deploy_to_prod: bool = False
3030
pr_environment_name: t.Optional[str] = None
31+
prod_branch_names_: t.Optional[str] = Field(default=None, alias="prod_branch_name")
3132

3233
@model_validator(mode="before")
3334
@classmethod
@@ -42,6 +43,12 @@ def _validate(cls, data: t.Any) -> t.Any:
4243

4344
return data
4445

46+
@property
47+
def prod_branch_names(self) -> t.List[str]:
48+
if self.prod_branch_names_:
49+
return [self.prod_branch_names_]
50+
return ["main", "master"]
51+
4552
FIELDS_FOR_ANALYTICS: t.ClassVar[t.Set[str]] = {
4653
"invalidate_environment_after_deploy",
4754
"enable_deploy_command",

sqlmesh/integrations/github/cicd/controller.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ def create_from_pull_request_url(cls, pull_request_url: str) -> PullRequestInfo:
7676
return cls(
7777
owner=owner,
7878
repo=repo,
79-
pr_number=pr_number,
79+
pr_number=int(pr_number),
8080
)
8181

8282

@@ -444,6 +444,10 @@ def modified_snapshots(self) -> t.Dict[SnapshotId, t.Union[Snapshot, SnapshotTab
444444
def removed_snapshots(self) -> t.Set[SnapshotId]:
445445
return set(self.prod_plan_with_gaps.context_diff.removed_snapshots)
446446

447+
@property
448+
def pr_targets_prod_branch(self) -> bool:
449+
return self._pull_request.base.ref in self.bot_config.prod_branch_names
450+
447451
@classmethod
448452
def _append_output(cls, key: str, value: str) -> None:
449453
"""

tests/integrations/github/cicd/fixtures.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,15 @@ def github_client(mocker: MockerFixture):
2525

2626
client_mock = mocker.MagicMock(spec=Github)
2727
mocker.patch("github.Github", client_mock)
28+
2829
mock_repository = mocker.MagicMock(spec=Repository)
30+
client_mock.get_repo.return_value = mock_repository
31+
2932
mock_pull_request = mocker.MagicMock(spec=PullRequest)
30-
mock_pull_request.get_reviews = mocker.MagicMock(
31-
side_effect=[mocker.MagicMock(spec=PullRequestReview)]
32-
)
33-
mock_repository.get_pull = mocker.MagicMock(side_effect=mock_pull_request)
34-
mock_repository.get_issue = mocker.MagicMock(side_effect=mocker.MagicMock(spec=Issue))
35-
client_mock.get_repo = mocker.MagicMock(side_effect=mock_repository)
33+
mock_pull_request.base.ref = "main"
34+
mock_pull_request.get_reviews.return_value = [mocker.MagicMock(spec=PullRequestReview)]
35+
mock_repository.get_pull.return_value = mock_pull_request
36+
mock_repository.get_issue.return_value = mocker.MagicMock(spec=Issue)
3637

3738
return client_mock
3839

tests/integrations/github/cicd/test_config.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ def test_load_yaml_config_default(tmp_path):
4040
assert config.cicd_bot.skip_pr_backfill
4141
assert config.cicd_bot.pr_include_unmodified is None
4242
assert config.cicd_bot.pr_environment_name is None
43+
assert config.cicd_bot.prod_branch_names == ["main", "master"]
4344

4445

4546
def test_load_yaml_config(tmp_path):
@@ -62,6 +63,7 @@ def test_load_yaml_config(tmp_path):
6263
skip_pr_backfill: false
6364
pr_include_unmodified: true
6465
pr_environment_name: "MyOverride"
66+
prod_branch_name: testing
6567
model_defaults:
6668
dialect: duckdb
6769
""",
@@ -85,6 +87,7 @@ def test_load_yaml_config(tmp_path):
8587
assert not config.cicd_bot.skip_pr_backfill
8688
assert config.cicd_bot.pr_include_unmodified
8789
assert config.cicd_bot.pr_environment_name == "MyOverride"
90+
assert config.cicd_bot.prod_branch_names == ["testing"]
8891

8992

9093
def test_load_python_config_defaults(tmp_path):
@@ -115,6 +118,7 @@ def test_load_python_config_defaults(tmp_path):
115118
assert config.cicd_bot.skip_pr_backfill
116119
assert config.cicd_bot.pr_include_unmodified is None
117120
assert config.cicd_bot.pr_environment_name is None
121+
assert config.cicd_bot.prod_branch_names == ["main", "master"]
118122

119123

120124
def test_load_python_config(tmp_path):
@@ -141,6 +145,7 @@ def test_load_python_config(tmp_path):
141145
skip_pr_backfill=False,
142146
pr_include_unmodified=True,
143147
pr_environment_name="MyOverride",
148+
prod_branch_name="testing",
144149
),
145150
model_defaults=ModelDefaultsConfig(dialect="duckdb"),
146151
)
@@ -166,6 +171,7 @@ def test_load_python_config(tmp_path):
166171
assert not config.cicd_bot.skip_pr_backfill
167172
assert config.cicd_bot.pr_include_unmodified
168173
assert config.cicd_bot.pr_environment_name == "MyOverride"
174+
assert config.cicd_bot.prod_branch_names == ["testing"]
169175

170176

171177
def test_validation(tmp_path):

tests/integrations/github/cicd/test_integration.py

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1948,3 +1948,182 @@ def test_pr_delete_model(
19481948
output
19491949
== "run_unit_tests=success\nhas_required_approval=success\ncreated_pr_environment=true\npr_environment_name=hello_world_2\npr_environment_synced=success\nprod_plan_preview=success\nprod_environment_synced=success\n"
19501950
)
1951+
1952+
1953+
@time_machine.travel("2023-01-01 15:00:00 UTC")
1954+
def test_has_required_approval_but_not_base_branch(
1955+
github_client,
1956+
make_controller,
1957+
make_mock_check_run,
1958+
make_mock_issue_comment,
1959+
make_pull_request_review,
1960+
tmp_path: pathlib.Path,
1961+
mocker: MockerFixture,
1962+
):
1963+
"""
1964+
PR with a non-breaking change and auto-categorization will be backfilled, but NOT automatically merged or deployed to production if it is branched from a non-production branch.
1965+
1966+
Scenario:
1967+
- PR is not merged
1968+
- PR has been approved by a required reviewer
1969+
- Tests passed
1970+
- PR Merge Method defined
1971+
- Delete environment is disabled
1972+
- Changes made in PR with auto-categorization
1973+
- PR is not merged, despite having required approval, since the base branch is not prod
1974+
"""
1975+
mock_repo = github_client.get_repo()
1976+
mock_repo.create_check_run = mocker.MagicMock(
1977+
side_effect=lambda **kwargs: make_mock_check_run(**kwargs)
1978+
)
1979+
1980+
created_comments: t.List[MockIssueComment] = []
1981+
mock_issue = mock_repo.get_issue()
1982+
mock_issue.create_comment = mocker.MagicMock(
1983+
side_effect=lambda comment: make_mock_issue_comment(
1984+
comment=comment, created_comments=created_comments
1985+
)
1986+
)
1987+
mock_issue.get_comments = mocker.MagicMock(side_effect=lambda: created_comments)
1988+
1989+
mock_pull_request = mock_repo.get_pull()
1990+
mock_pull_request.base.ref = "feature/branch"
1991+
mock_pull_request.get_reviews = mocker.MagicMock(
1992+
side_effect=lambda: [make_pull_request_review(username="test_github", state="APPROVED")]
1993+
)
1994+
mock_pull_request.merged = False
1995+
mock_pull_request.merge = mocker.MagicMock()
1996+
1997+
controller = make_controller(
1998+
"tests/fixtures/github/pull_request_synchronized.json",
1999+
github_client,
2000+
bot_config=GithubCICDBotConfig(
2001+
merge_method=MergeMethod.MERGE,
2002+
invalidate_environment_after_deploy=False,
2003+
auto_categorize_changes=CategorizerConfig.all_full(),
2004+
default_pr_start=None,
2005+
skip_pr_backfill=False,
2006+
),
2007+
mock_out_context=False,
2008+
)
2009+
controller._context.plan("prod", no_prompts=True, auto_apply=True)
2010+
controller._context.users = [
2011+
User(username="test", github_username="test_github", roles=[UserRole.REQUIRED_APPROVER])
2012+
]
2013+
# Make a non-breaking change
2014+
model = controller._context.get_model("sushi.waiter_revenue_by_day").copy()
2015+
model.query.select(exp.alias_("1", "new_col"), copy=False)
2016+
controller._context.upsert_model(model)
2017+
2018+
github_output_file = tmp_path / "github_output.txt"
2019+
2020+
with mock.patch.dict(os.environ, {"GITHUB_OUTPUT": str(github_output_file)}):
2021+
command._run_all(controller)
2022+
2023+
assert "SQLMesh - Run Unit Tests" in controller._check_run_mapping
2024+
test_checks_runs = controller._check_run_mapping["SQLMesh - Run Unit Tests"].all_kwargs
2025+
assert len(test_checks_runs) == 3
2026+
assert GithubCheckStatus(test_checks_runs[0]["status"]).is_queued
2027+
assert GithubCheckStatus(test_checks_runs[1]["status"]).is_in_progress
2028+
assert GithubCheckStatus(test_checks_runs[2]["status"]).is_completed
2029+
assert GithubCheckConclusion(test_checks_runs[2]["conclusion"]).is_success
2030+
assert test_checks_runs[2]["output"]["title"] == "Tests Passed"
2031+
assert (
2032+
test_checks_runs[2]["output"]["summary"].strip()
2033+
== "**Successfully Ran `3` Tests Against `duckdb`**"
2034+
)
2035+
2036+
assert "SQLMesh - PR Environment Synced" in controller._check_run_mapping
2037+
pr_checks_runs = controller._check_run_mapping["SQLMesh - PR Environment Synced"].all_kwargs
2038+
assert len(pr_checks_runs) == 3
2039+
assert GithubCheckStatus(pr_checks_runs[0]["status"]).is_queued
2040+
assert GithubCheckStatus(pr_checks_runs[1]["status"]).is_in_progress
2041+
assert GithubCheckStatus(pr_checks_runs[2]["status"]).is_completed
2042+
assert GithubCheckConclusion(pr_checks_runs[2]["conclusion"]).is_success
2043+
assert pr_checks_runs[2]["output"]["title"] == "PR Virtual Data Environment: hello_world_2"
2044+
assert (
2045+
pr_checks_runs[2]["output"]["summary"]
2046+
== """<table><thead><tr><th colspan="3">PR Environment Summary</th></tr><tr><th>Model</th><th>Change Type</th><th>Dates Loaded</th></tr></thead><tbody><tr><td>sushi.waiter_revenue_by_day</td><td>Non-breaking</td><td>2022-12-25 - 2022-12-31</td></tr></tbody></table>"""
2047+
)
2048+
2049+
assert "SQLMesh - Prod Plan Preview" in controller._check_run_mapping
2050+
prod_plan_preview_checks_runs = controller._check_run_mapping[
2051+
"SQLMesh - Prod Plan Preview"
2052+
].all_kwargs
2053+
assert len(prod_plan_preview_checks_runs) == 3
2054+
assert GithubCheckStatus(prod_plan_preview_checks_runs[0]["status"]).is_queued
2055+
assert GithubCheckStatus(prod_plan_preview_checks_runs[1]["status"]).is_in_progress
2056+
assert GithubCheckStatus(prod_plan_preview_checks_runs[2]["status"]).is_completed
2057+
assert GithubCheckConclusion(prod_plan_preview_checks_runs[2]["conclusion"]).is_success
2058+
expected_prod_plan_summary = """\n**Summary of differences from `prod`:**
2059+
2060+
**Directly Modified:**
2061+
- `sushi.waiter_revenue_by_day`
2062+
```diff
2063+
---
2064+
2065+
+++
2066+
2067+
@@ -16,7 +16,8 @@
2068+
2069+
SELECT
2070+
CAST(o.waiter_id AS INT) AS waiter_id,
2071+
CAST(SUM(oi.quantity * i.price) AS DOUBLE) AS revenue,
2072+
- CAST(o.event_date AS DATE) AS event_date
2073+
+ CAST(o.event_date AS DATE) AS event_date,
2074+
+ 1 AS new_col
2075+
FROM sushi.orders AS o
2076+
LEFT JOIN sushi.order_items AS oi
2077+
ON o.id = oi.order_id AND o.event_date = oi.event_date
2078+
```
2079+
2080+
**Indirectly Modified:**
2081+
- `sushi.top_waiters`
2082+
2083+
"""
2084+
assert prod_plan_preview_checks_runs[2]["output"]["title"] == "Prod Plan Preview"
2085+
assert prod_plan_preview_checks_runs[2]["output"]["summary"] == expected_prod_plan_summary
2086+
2087+
assert "SQLMesh - Prod Environment Synced" not in controller._check_run_mapping
2088+
2089+
assert "SQLMesh - Has Required Approval" in controller._check_run_mapping
2090+
approval_checks_runs = controller._check_run_mapping[
2091+
"SQLMesh - Has Required Approval"
2092+
].all_kwargs
2093+
assert len(approval_checks_runs) == 3
2094+
assert GithubCheckStatus(approval_checks_runs[0]["status"]).is_queued
2095+
assert GithubCheckStatus(approval_checks_runs[1]["status"]).is_in_progress
2096+
assert GithubCheckStatus(approval_checks_runs[2]["status"]).is_completed
2097+
assert GithubCheckConclusion(approval_checks_runs[2]["conclusion"]).is_success
2098+
assert (
2099+
approval_checks_runs[2]["output"]["title"]
2100+
== "Obtained approval from required approvers: test_github"
2101+
)
2102+
assert (
2103+
approval_checks_runs[2]["output"]["summary"]
2104+
== """**List of possible required approvers:**
2105+
- `test_github`
2106+
"""
2107+
)
2108+
2109+
assert len(get_environment_objects(controller, "hello_world_2")) == 2
2110+
assert get_num_days_loaded(controller, "hello_world_2", "waiter_revenue_by_day") == 7
2111+
assert "new_col" in get_columns(controller, "hello_world_2", "waiter_revenue_by_day")
2112+
assert "new_col" not in get_columns(controller, None, "waiter_revenue_by_day")
2113+
2114+
assert not mock_pull_request.merge.called
2115+
2116+
assert len(created_comments) == 1
2117+
assert (
2118+
created_comments[0].body
2119+
== """:robot: **SQLMesh Bot Info** :robot:
2120+
- :eyes: To **review** this PR's changes, use virtual data environment:
2121+
- `hello_world_2`"""
2122+
)
2123+
2124+
with open(github_output_file, "r", encoding="utf-8") as f:
2125+
output = f.read()
2126+
assert (
2127+
output
2128+
== "run_unit_tests=success\nhas_required_approval=success\ncreated_pr_environment=true\npr_environment_name=hello_world_2\npr_environment_synced=success\nprod_plan_preview=success\n"
2129+
)

0 commit comments

Comments
 (0)