Skip to content

Commit 3e34f95

Browse files
MattPColemanrelekang
authored andcommitted
feat: include additional changes in release commits
Add new config keys, `pre_commit_command` and `commit_additional_files`, to allow custom file changes alongside the release commits.
1 parent 09af5f1 commit 3e34f95

File tree

8 files changed

+289
-1
lines changed

8 files changed

+289
-1
lines changed

docs/configuration.rst

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,19 @@ set this option to `false`.
121121

122122
Default: `true`.
123123

124+
.. _config-pre_commit_command:
125+
126+
``pre_commit_command``
127+
----------------------
128+
If this command is provided, it will be run prior to the creation of the release commit.
129+
130+
.. _config-include_additional_files:
131+
132+
``include_additional_files``
133+
----------------------------
134+
A comma-separated list of files to be included within the release commit. This can include
135+
any files created/modified by the ``pre_commit_command``.
136+
124137
Commit Parsing
125138
==============
126139

semantic_release/cli.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
post_changelog,
3030
upload_to_release,
3131
)
32+
from .pre_commit import run_pre_commit, should_run_pre_commit
3233
from .repository import ArtifactRepo
3334
from .settings import config, overload_configuration
3435
from .vcs_helpers import (
@@ -38,6 +39,7 @@
3839
get_repository_owner_and_name,
3940
push_new_version,
4041
tag_new_version,
42+
update_additional_files,
4143
update_changelog_file,
4244
)
4345

@@ -272,8 +274,13 @@ def publish(retry: bool = False, noop: bool = False, **kwargs):
272274
previous_version=current_version,
273275
)
274276

277+
if should_run_pre_commit():
278+
logger.info("Running pre-commit command")
279+
run_pre_commit()
280+
275281
if not retry:
276282
update_changelog_file(new_version, changelog_md)
283+
update_additional_files()
277284
bump_version(new_version, level_bump)
278285
# A new version was released
279286
logger.info("Pushing new version")

semantic_release/defaults.cfg

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ changelog_placeholder=<!--next-version-placeholder-->
1212
changelog_scope=true
1313
changelog_sections=feature,fix,breaking,documentation,performance,:boom:,:sparkles:,:children_crossing:,:lipstick:,:iphone:,:egg:,:chart_with_upwards_trend:,:ambulance:,:lock:,:bug:,:zap:,:goal_net:,:alien:,:wheelchair:,:speech_balloon:,:mag:,:apple:,:penguin:,:checkered_flag:,:robot:,:green_apple:,Other
1414
check_build_status=false
15+
include_additional_files=
1516
commit_message=Automatically generated by python-semantic-release
1617
commit_parser=semantic_release.history.angular_parser
1718
commit_subject={version}
@@ -26,6 +27,7 @@ minor_emoji=:sparkles:,:children_crossing:,:lipstick:,:iphone:,:egg:,:chart_with
2627
minor_tag=:sparkles:
2728
patch_emoji=:ambulance:,:lock:,:bug:,:zap:,:goal_net:,:alien:,:wheelchair:,:speech_balloon:,:mag:,:apple:,:penguin:,:checkered_flag:,:robot:,:green_apple:
2829
patch_without_tag=false
30+
pre_commit_command=
2931
pypi_pass_var=PYPI_PASSWORD
3032
pypi_token_var=PYPI_TOKEN
3133
pypi_user_var=PYPI_USERNAME

semantic_release/pre_commit.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
"""Run commands prior to the release commit
2+
"""
3+
import logging
4+
5+
from invoke import run
6+
7+
from .settings import config
8+
9+
logger = logging.getLogger(__name__)
10+
11+
12+
def should_run_pre_commit():
13+
command = config.get("pre_commit_command")
14+
return bool(command)
15+
16+
17+
def run_pre_commit():
18+
command = config.get("pre_commit_command")
19+
logger.debug(f"Running {command}")
20+
run(command)

semantic_release/vcs_helpers.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from datetime import date
77
from functools import wraps
88
from pathlib import Path, PurePath
9-
from typing import Optional, Tuple
9+
from typing import List, Optional, Tuple
1010
from urllib.parse import urlsplit
1111

1212
from git import GitCommandError, InvalidGitRepositoryError, Repo
@@ -210,6 +210,36 @@ def update_changelog_file(version: str, content_to_add: str):
210210
repo.git.add(str(git_path.relative_to(str(repo.working_dir))))
211211

212212

213+
def get_changed_files(repo: Repo) -> List[str]:
214+
"""
215+
Get untracked / dirty files in the given git repo.
216+
217+
:param repo: Git repo to check.
218+
:return: A list of filenames.
219+
"""
220+
untracked_files = repo.untracked_files
221+
dirty_files = [item.a_path for item in repo.index.diff(None)]
222+
return [*untracked_files, *dirty_files]
223+
224+
225+
@check_repo
226+
@LoggedFunction(logger)
227+
def update_additional_files():
228+
"""
229+
Add specified files to VCS, if they've changed.
230+
"""
231+
changed_files = get_changed_files(repo)
232+
233+
include_additional_files = config.get("include_additional_files")
234+
if include_additional_files:
235+
for filename in include_additional_files.split(","):
236+
if filename in changed_files:
237+
logger.debug(f"Updated file: {filename}")
238+
repo.git.add(filename)
239+
else:
240+
logger.warning(f"File {filename} shows no changes, cannot update it.")
241+
242+
213243
@check_repo
214244
@LoggedFunction(logger)
215245
def tag_new_version(version: str):

tests/test_cli.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,6 +452,95 @@ def test_version_retry(mocker):
452452
mock_get_new.assert_called_once_with("current", "patch")
453453

454454

455+
def test_publish_should_not_run_pre_commit_by_default(mocker):
456+
mocker.patch("semantic_release.cli.checkout")
457+
mocker.patch("semantic_release.cli.ci_checks.check")
458+
mocker.patch.object(ArtifactRepo, "upload")
459+
mocker.patch("semantic_release.cli.upload_to_release")
460+
mocker.patch("semantic_release.cli.post_changelog", lambda *x: True)
461+
mocker.patch("semantic_release.cli.push_new_version", return_value=True)
462+
mocker.patch("semantic_release.cli.should_bump_version", return_value=True)
463+
mocker.patch("semantic_release.cli.markdown_changelog", lambda *x, **y: "CHANGES")
464+
mocker.patch("semantic_release.cli.update_changelog_file")
465+
mocker.patch("semantic_release.cli.bump_version")
466+
mocker.patch("semantic_release.cli.get_new_version", lambda *x: "2.0.0")
467+
mocker.patch("semantic_release.cli.check_token", lambda: True)
468+
run_pre_commit = mocker.patch("semantic_release.cli.run_pre_commit")
469+
mocker.patch(
470+
"semantic_release.cli.config.get",
471+
wrapped_config_get(
472+
remove_dist=False,
473+
upload_to_pypi=False,
474+
upload_to_release=False,
475+
),
476+
)
477+
mocker.patch("semantic_release.cli.update_changelog_file", lambda *x, **y: None)
478+
479+
publish()
480+
481+
assert not run_pre_commit.called
482+
483+
484+
def test_publish_should_not_run_pre_commit_with_empty_command(mocker):
485+
mocker.patch("semantic_release.cli.checkout")
486+
mocker.patch("semantic_release.cli.ci_checks.check")
487+
mocker.patch.object(ArtifactRepo, "upload")
488+
mocker.patch("semantic_release.cli.upload_to_release")
489+
mocker.patch("semantic_release.cli.post_changelog", lambda *x: True)
490+
mocker.patch("semantic_release.cli.push_new_version", return_value=True)
491+
mocker.patch("semantic_release.cli.should_bump_version", return_value=True)
492+
mocker.patch("semantic_release.cli.markdown_changelog", lambda *x, **y: "CHANGES")
493+
mocker.patch("semantic_release.cli.update_changelog_file")
494+
mocker.patch("semantic_release.cli.bump_version")
495+
mocker.patch("semantic_release.cli.get_new_version", lambda *x: "2.0.0")
496+
mocker.patch("semantic_release.cli.check_token", lambda: True)
497+
run_pre_commit = mocker.patch("semantic_release.cli.run_pre_commit")
498+
mocker.patch(
499+
"semantic_release.cli.config.get",
500+
wrapped_config_get(
501+
remove_dist=False,
502+
upload_to_pypi=False,
503+
upload_to_release=False,
504+
pre_commit_command="",
505+
),
506+
)
507+
mocker.patch("semantic_release.cli.update_changelog_file", lambda *x, **y: None)
508+
509+
publish()
510+
511+
assert not run_pre_commit.called
512+
513+
514+
def test_publish_should_run_pre_commit_if_provided(mocker):
515+
mocker.patch("semantic_release.cli.checkout")
516+
mocker.patch("semantic_release.cli.ci_checks.check")
517+
mocker.patch.object(ArtifactRepo, "upload")
518+
mocker.patch("semantic_release.cli.upload_to_release")
519+
mocker.patch("semantic_release.cli.post_changelog", lambda *x: True)
520+
mocker.patch("semantic_release.cli.push_new_version", return_value=True)
521+
mocker.patch("semantic_release.cli.should_bump_version", return_value=True)
522+
mocker.patch("semantic_release.cli.markdown_changelog", lambda *x, **y: "CHANGES")
523+
mocker.patch("semantic_release.cli.update_changelog_file")
524+
mocker.patch("semantic_release.cli.bump_version")
525+
mocker.patch("semantic_release.cli.get_new_version", lambda *x: "2.0.0")
526+
mocker.patch("semantic_release.cli.check_token", lambda: True)
527+
run_pre_commit = mocker.patch("semantic_release.cli.run_pre_commit")
528+
mocker.patch(
529+
"semantic_release.cli.config.get",
530+
wrapped_config_get(
531+
remove_dist=False,
532+
upload_to_pypi=False,
533+
upload_to_release=False,
534+
pre_commit_command="echo \"Hello, world.\"",
535+
),
536+
)
537+
mocker.patch("semantic_release.cli.update_changelog_file", lambda *x, **y: None)
538+
539+
publish()
540+
541+
assert run_pre_commit.called
542+
543+
455544
def test_publish_should_not_upload_to_pypi_if_option_is_false(mocker):
456545
mocker.patch("semantic_release.cli.checkout")
457546
mocker.patch("semantic_release.cli.ci_checks.check")

tests/test_pre_commit.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
from semantic_release.pre_commit import run_pre_commit, should_run_pre_commit
2+
3+
import pytest
4+
5+
6+
@pytest.mark.parametrize(
7+
"commands",
8+
["make do-stuff", "echo hello > somefile.txt"],
9+
)
10+
def test_pre_commit_command(mocker, commands):
11+
mocker.patch("semantic_release.pre_commit.config.get", lambda *a: commands)
12+
mock_run = mocker.patch("semantic_release.pre_commit.run")
13+
run_pre_commit()
14+
mock_run.assert_called_once_with(commands)
15+
16+
17+
@pytest.mark.parametrize(
18+
"config,expected",
19+
[
20+
(
21+
{
22+
"pre_commit_command": "make generate_some_file",
23+
},
24+
True,
25+
),
26+
(
27+
{
28+
"pre_commit_command": "cmd",
29+
},
30+
True,
31+
),
32+
(
33+
{
34+
"pre_commit_command": "",
35+
},
36+
False,
37+
),
38+
(
39+
{},
40+
False,
41+
),
42+
],
43+
)
44+
def test_should_run_pre_commit_command(config, expected, mocker):
45+
mocker.patch("semantic_release.cli.config.get", lambda key: config.get(key))
46+
assert should_run_pre_commit() == expected

tests/test_vcs_helpers.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
push_new_version,
1919
tag_new_version,
2020
update_changelog_file,
21+
update_additional_files,
2122
)
2223

2324
from . import mock, wrapped_config_get
@@ -446,3 +447,83 @@ def test_update_changelog_file_missing_placeholder(mock_git, mocker):
446447
mock_git.add.assert_not_called()
447448
mocked_read_text.assert_called_once()
448449
mocked_write_text.assert_not_called()
450+
451+
452+
@pytest.mark.parametrize(
453+
"include_additional_files",
454+
[
455+
"",
456+
",",
457+
"somefile.txt",
458+
"somefile.txt,anotherfile.rst",
459+
"somefile.txt,anotherfile.rst,finalfile.md",
460+
],
461+
)
462+
def test_update_additional_files_with_no_changes(
463+
mock_git,
464+
mocker,
465+
include_additional_files,
466+
):
467+
"""
468+
Since we have no file changes, we expect `add` to never be called,
469+
regardless of the config.
470+
"""
471+
mocker.patch(
472+
"semantic_release.vcs_helpers.config.get",
473+
wrapped_config_get(**{"include_additional_files": include_additional_files}),
474+
)
475+
mocker.patch("semantic_release.vcs_helpers.get_changed_files", return_value=[])
476+
update_additional_files()
477+
mock_git.add.assert_not_called()
478+
479+
480+
def test_update_additional_files_single_changed_file(mock_git, mocker):
481+
"""
482+
We expect to add the single file corresponding to config & changes.
483+
"""
484+
mocker.patch(
485+
"semantic_release.vcs_helpers.config.get",
486+
wrapped_config_get(**{"include_additional_files": "somefile.txt"}),
487+
)
488+
mocker.patch(
489+
"semantic_release.vcs_helpers.get_changed_files",
490+
return_value=["somefile.txt"],
491+
)
492+
update_additional_files()
493+
mock_git.add.assert_called_once_with("somefile.txt")
494+
495+
496+
def test_update_additional_files_one_in_config_two_changes(mock_git, mocker):
497+
"""
498+
Given two file changes, but only one referenced in the config, we
499+
expect that single file to be added.
500+
"""
501+
mocker.patch(
502+
"semantic_release.vcs_helpers.config.get",
503+
wrapped_config_get(**{"include_additional_files": "anotherfile.txt"}),
504+
)
505+
mocker.patch(
506+
"semantic_release.vcs_helpers.get_changed_files",
507+
return_value=["somefile.txt", "anotherfile.txt"],
508+
)
509+
update_additional_files()
510+
mock_git.add.assert_called_once_with("anotherfile.txt")
511+
512+
513+
def test_update_additional_files_two_in_config_one_change(mock_git, mocker):
514+
"""
515+
Given two file changes, but only one referenced in the config, we
516+
expect that single file to be added.
517+
"""
518+
mocker.patch(
519+
"semantic_release.vcs_helpers.config.get",
520+
wrapped_config_get(
521+
**{"include_additional_files": "somefile.txt,anotherfile.txt"}
522+
),
523+
)
524+
mocker.patch(
525+
"semantic_release.vcs_helpers.get_changed_files",
526+
return_value=["anotherfile.txt"],
527+
)
528+
update_additional_files()
529+
mock_git.add.assert_called_once_with("anotherfile.txt")

0 commit comments

Comments
 (0)