Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Repo specific exemptions #158

Merged
merged 12 commits into from
Jun 1, 2024
1 change: 1 addition & 0 deletions .env-example
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ GH_TOKEN = ""
GROUP_DEPENDENCIES = ""
ORGANIZATION = ""
PROJECT_ID = ""
REPO_SPECIFIC_EXEMPTIONS = ""
zkoppert marked this conversation as resolved.
Show resolved Hide resolved
REPOSITORY = ""
TITLE = ""
TYPE = ""
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ This action can be configured to authenticate with GitHub App Installation or Pe
| `BATCH_SIZE` | False | None | Set this to define the maximum amount of eligible repositories for every run. This is useful if you are targeting large organizations and you don't want to flood repositories with pull requests / issues. ex: if you want to target 20 repositories per time, set this to 20. |
| `ENABLE_SECURITY_UPDATES` | False | true | If set to true, Evergreen will enable [Dependabot security updates](https://docs.github.com/en/code-security/dependabot/dependabot-security-updates/configuring-dependabot-security-updates) on target repositories. Note that the GitHub token needs to have the `administration:write` permission on every repository in scope to successfully enable security updates. |
| `EXEMPT_ECOSYSTEMS` | False | "" | A list of [package ecosystems](https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#package-ecosystem) to exempt from the generated dependabot configuration. To ignore ecosystems set this to one or more of `bundler`,`cargo`, `composer`, `pip`, `docker`, `npm`, `gomod`, `mix`, `nuget`, `github-actions` and `terraform`. ex: if you don't want Dependabot to update Dockerfiles and Github Actions you can set this to `docker,github-actions`. |
| `REPO_SPECIFIC_EXEMPTIONS` | False | "" | A list of repositories that should be exempt from specific package ecosystems similar to EXEMPT_ECOSYSTEMS but those apply to all repositories. ex: `org1/repo1:docker,github-actions;org1/repo2:pip` would set exempt_ecosystems for `org1/repo1` to be `['docker', 'github-actions']`, and for `org1/repo2` it would be `['pip']`, while for every other repository evaluated, it would be set by the env variable `EXEMPT_ECOSYSTEMS`. NOTE: If you want specific exemptions to be added on top of the already specified global exemptions, you need to add the global exemptions to each repo specific exemption. |

### Example workflows

Expand Down
14 changes: 13 additions & 1 deletion dependabot_file.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,11 @@ def make_dependabot_config(ecosystem, group_dependencies, indent) -> str:


def build_dependabot_file(
repo, group_dependencies, exempt_ecosystems, existing_config
repo,
group_dependencies,
exempt_ecosystems,
repo_specific_exemptions,
existing_config,
) -> str | None:
"""
Build the dependabot.yml file for a repo based on the repo contents
Expand All @@ -41,6 +45,7 @@ def build_dependabot_file(
repo: the repository to build the dependabot.yml file for
group_dependencies: whether to group dependencies in the dependabot.yml file
exempt_ecosystems: the list of ecosystems to ignore
repo_specific_exemptions: the list of ecosystems to ignore for a specific repo
existing_config: the existing dependabot configuration file or None if it doesn't exist

Returns:
Expand Down Expand Up @@ -83,6 +88,13 @@ def build_dependabot_file(

add_existing_ecosystem_to_exempt_list(exempt_ecosystems, existing_config)

# If there are repository specific exemptions,
# overwrite the global exemptions for this repo only
if repo_specific_exemptions and repo.full_name in repo_specific_exemptions:
exempt_ecosystems = []
for ecosystem in repo_specific_exemptions[repo.full_name]:
exempt_ecosystems.append(ecosystem)

package_managers = {
"bundler": ["Gemfile", "Gemfile.lock"],
"npm": ["package.json", "package-lock.json", "yarn.lock"],
Expand Down
57 changes: 57 additions & 0 deletions env.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,55 @@ def get_int_env_var(env_var_name: str) -> int | None:
return None


def parse_repo_specific_exemptions(repo_specific_exemptions_str: str) -> dict:
"""Parse the REPO_SPECIFIC_EXEMPTIONS environment variable into a dictionary.

Args:
repo_specific_exemptions_str: The REPO_SPECIFIC_EXEMPTIONS environment variable as a string.

Returns:
A dictionary where keys are repository names and values are lists of exempt ecosystems.
"""
exemptions_dict = {}
if repo_specific_exemptions_str:
# if repo_specific_exemptions_str doesn't have a ; and : character, it's not valid
if (
repo_specific_exemptions_str.find(";") == -1
or repo_specific_exemptions_str.find(":") == -1
):
zkoppert marked this conversation as resolved.
Show resolved Hide resolved
raise ValueError(
"REPO_SPECIFIC_EXEMPTIONS environment variable not formatted correctly"
)
exemptions_list = repo_specific_exemptions_str.split(";")
for exemption in exemptions_list:
if (
exemption == ""
): # Account for final ; in the repo_specific_exemptions_str
continue
repo, ecosystems = exemption.split(":")
for ecosystem in ecosystems.split(","):
if ecosystem not in [
"bundler",
"npm",
"pip",
"cargo",
"docker",
"gomod",
"composer",
"mix",
"github-actions",
"nuget",
"terraform",
zkoppert marked this conversation as resolved.
Show resolved Hide resolved
]:
raise ValueError(
"REPO_SPECIFIC_EXEMPTIONS environment variable not formatted correctly. Unrecognized package-ecosystem."
)
exemptions_dict[repo.strip()] = [
ecosystem.strip() for ecosystem in ecosystems.split(",")
]
return exemptions_dict


def get_env_vars(test: bool = False) -> tuple[
str | None,
list[str],
Expand All @@ -65,6 +114,7 @@ def get_env_vars(test: bool = False) -> tuple[
bool | None,
list[str],
bool | None,
dict,
]:
"""
Get the environment variables for use in the action.
Expand Down Expand Up @@ -93,6 +143,7 @@ def get_env_vars(test: bool = False) -> tuple[
enable_security_updates (bool): Whether to enable security updates in target repositories
exempt_ecosystems_list (list[str]): A list of package ecosystems to exempt from the action
update_existing (bool): Whether to update existing dependabot configuration files
repo_specific_exemptions (dict): A dictionary of per repository ecosystem exemptions
"""

if not test:
Expand Down Expand Up @@ -234,6 +285,11 @@ def get_env_vars(test: bool = False) -> tuple[

update_existing = get_bool_env_var("UPDATE_EXISTING")

repo_specific_exemptions_str = os.getenv("REPO_SPECIFIC_EXEMPTIONS", "")
repo_specific_exemptions = parse_repo_specific_exemptions(
repo_specific_exemptions_str
)

return (
organization,
repositories_list,
Expand All @@ -256,4 +312,5 @@ def get_env_vars(test: bool = False) -> tuple[
enable_security_updates_bool,
exempt_ecosystems_list,
update_existing,
repo_specific_exemptions,
)
7 changes: 6 additions & 1 deletion evergreen.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ def main(): # pragma: no cover
enable_security_updates,
exempt_ecosystems,
update_existing,
repo_specific_exemptions,
) = env.get_env_vars()

# Auth to GitHub.com or GHE
Expand Down Expand Up @@ -96,7 +97,11 @@ def main(): # pragma: no cover
print("Checking " + repo.full_name + " for compatible package managers")
# Try to detect package managers and build a dependabot file
dependabot_file = build_dependabot_file(
repo, group_dependencies, exempt_ecosystems, existing_config
repo,
group_dependencies,
exempt_ecosystems,
repo_specific_exemptions,
existing_config,
)

if dependabot_file is None:
Expand Down
45 changes: 27 additions & 18 deletions test_dependabot_file.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ def test_not_found_error(self):
response.status_code = 404
repo.file_contents.side_effect = github3.exceptions.NotFoundError(resp=response)

result = build_dependabot_file(repo, False, [], None)
result = build_dependabot_file(repo, False, [], {}, None)
self.assertEqual(result, None)

def test_build_dependabot_file_with_bundler(self):
Expand All @@ -38,7 +38,7 @@ def test_build_dependabot_file_with_bundler(self):
schedule:
interval: 'weekly'
"""
result = build_dependabot_file(repo, False, [], None)
result = build_dependabot_file(repo, False, [], {}, None)
self.assertEqual(result, expected_result)

def test_build_dependabot_file_with_existing_config_bundler_no_update(self):
Expand All @@ -51,7 +51,7 @@ def test_build_dependabot_file_with_existing_config_bundler_no_update(self):
existing_config = MagicMock()
existing_config.decoded = b'---\nversion: 2\nupdates:\n - package-ecosystem: "bundler"\n\
directory: "/"\n schedule:\n interval: "weekly"\n commit-message:\n prefix: "chore(deps)"\n'
result = build_dependabot_file(repo, False, [], existing_config)
result = build_dependabot_file(repo, False, [], {}, existing_config)
self.assertEqual(result, expected_result)

def test_build_dependabot_file_with_2_space_indent_existing_config_bundler_with_update(
Expand Down Expand Up @@ -80,7 +80,7 @@ def test_build_dependabot_file_with_2_space_indent_existing_config_bundler_with_
existing_config = MagicMock()
existing_config.decoded = b'---\nversion: 2\nupdates:\n - package-ecosystem: "pip"\n directory: "/"\n\
schedule:\n interval: "weekly"\n commit-message:\n prefix: "chore(deps)"\n'
result = build_dependabot_file(repo, False, [], existing_config)
result = build_dependabot_file(repo, False, [], {}, existing_config)
self.assertEqual(result, expected_result)

def test_build_dependabot_file_with_weird_space_indent_existing_config_bundler_with_update(
Expand All @@ -95,7 +95,7 @@ def test_build_dependabot_file_with_weird_space_indent_existing_config_bundler_w
existing_config = MagicMock()
existing_config.decoded = b'---\nversion: 2\nupdates:\n- package-ecosystem: "pip"\n directory: "/"\n\
schedule:\n interval: "weekly"\n commit-message:\n prefix: "chore(deps)"\n'
result = build_dependabot_file(repo, False, [], existing_config)
result = build_dependabot_file(repo, False, [], {}, existing_config)
self.assertEqual(result, None)

def test_build_dependabot_file_with_npm(self):
Expand All @@ -113,7 +113,7 @@ def test_build_dependabot_file_with_npm(self):
schedule:
interval: 'weekly'
"""
result = build_dependabot_file(repo, False, [], None)
result = build_dependabot_file(repo, False, [], {}, None)
self.assertEqual(result, expected_result)

def test_build_dependabot_file_with_pip(self):
Expand All @@ -137,7 +137,7 @@ def test_build_dependabot_file_with_pip(self):
schedule:
interval: 'weekly'
"""
result = build_dependabot_file(repo, False, [], None)
result = build_dependabot_file(repo, False, [], {}, None)
self.assertEqual(result, expected_result)

def test_build_dependabot_file_with_cargo(self):
Expand All @@ -158,7 +158,7 @@ def test_build_dependabot_file_with_cargo(self):
schedule:
interval: 'weekly'
"""
result = build_dependabot_file(repo, False, [], None)
result = build_dependabot_file(repo, False, [], {}, None)
self.assertEqual(result, expected_result)

def test_build_dependabot_file_with_gomod(self):
Expand All @@ -174,7 +174,7 @@ def test_build_dependabot_file_with_gomod(self):
schedule:
interval: 'weekly'
"""
result = build_dependabot_file(repo, False, [], None)
result = build_dependabot_file(repo, False, [], {}, None)
self.assertEqual(result, expected_result)

def test_build_dependabot_file_with_composer(self):
Expand All @@ -195,7 +195,7 @@ def test_build_dependabot_file_with_composer(self):
schedule:
interval: 'weekly'
"""
result = build_dependabot_file(repo, False, [], None)
result = build_dependabot_file(repo, False, [], {}, None)
self.assertEqual(result, expected_result)

def test_build_dependabot_file_with_hex(self):
Expand All @@ -216,7 +216,7 @@ def test_build_dependabot_file_with_hex(self):
schedule:
interval: 'weekly'
"""
result = build_dependabot_file(repo, False, [], None)
result = build_dependabot_file(repo, False, [], {}, None)
self.assertEqual(result, expected_result)

def test_build_dependabot_file_with_nuget(self):
Expand All @@ -232,7 +232,7 @@ def test_build_dependabot_file_with_nuget(self):
schedule:
interval: 'weekly'
"""
result = build_dependabot_file(repo, False, [], None)
result = build_dependabot_file(repo, False, [], {}, None)
self.assertEqual(result, expected_result)

def test_build_dependabot_file_with_docker(self):
Expand All @@ -248,7 +248,7 @@ def test_build_dependabot_file_with_docker(self):
schedule:
interval: 'weekly'
"""
result = build_dependabot_file(repo, False, [], None)
result = build_dependabot_file(repo, False, [], {}, None)
self.assertEqual(result, expected_result)

def test_build_dependabot_file_with_terraform_with_files(self):
Expand All @@ -269,7 +269,7 @@ def test_build_dependabot_file_with_terraform_with_files(self):
schedule:
interval: 'weekly'
"""
result = build_dependabot_file(repo, False, [], None)
result = build_dependabot_file(repo, False, [], {}, None)
self.assertEqual(result, expected_result)

def test_build_dependabot_file_with_terraform_without_files(self):
Expand All @@ -281,7 +281,7 @@ def test_build_dependabot_file_with_terraform_without_files(self):

# Test absence of Terraform files
repo.directory_contents.side_effect = lambda path: [] if path == "/" else []
result = build_dependabot_file(repo, False, [], None)
result = build_dependabot_file(repo, False, [], {}, None)
self.assertIsNone(result)

# Test empty repository
Expand All @@ -290,7 +290,7 @@ def test_build_dependabot_file_with_terraform_without_files(self):
repo.directory_contents.side_effect = github3.exceptions.NotFoundError(
resp=response
)
result = build_dependabot_file(repo, False, [], None)
result = build_dependabot_file(repo, False, [], {}, None)
self.assertIsNone(result)

def test_build_dependabot_file_with_groups(self):
Expand All @@ -311,15 +311,24 @@ def test_build_dependabot_file_with_groups(self):
development-dependencies:
dependency-type: 'development'
"""
result = build_dependabot_file(repo, True, [], None)
result = build_dependabot_file(repo, True, [], {}, None)
self.assertEqual(result, expected_result)

def test_build_dependabot_file_with_exempt_ecosystems(self):
"""Test that the dependabot.yml file is built correctly with exempted ecosystems"""
repo = MagicMock()
repo.file_contents.side_effect = lambda filename: filename == "Dockerfile"

result = build_dependabot_file(repo, False, ["docker"], None)
result = build_dependabot_file(repo, False, ["docker"], {}, None)
self.assertEqual(result, None)

def test_build_dependabot_file_with_repo_specific_exempt_ecosystems(self):
"""Test that the dependabot.yml file is built correctly with exempted ecosystems"""
repo = MagicMock()
repo.full_name = "test/test"
repo.file_contents.side_effect = lambda filename: filename == "Dockerfile"

result = build_dependabot_file(repo, False, [], {"test/test": ["docker"]}, None)
self.assertEqual(result, None)

def test_add_existing_ecosystem_to_exempt_list(self):
Expand Down
Loading