Skip to content

Commit 8998025

Browse files
authored
Merge pull request #158 from github/repo-specific-exemptions
2 parents 32dd499 + 6dbaedf commit 8998025

File tree

7 files changed

+232
-26
lines changed

7 files changed

+232
-26
lines changed

.env-example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ GH_TOKEN = ""
1111
GROUP_DEPENDENCIES = ""
1212
ORGANIZATION = ""
1313
PROJECT_ID = ""
14+
REPO_SPECIFIC_EXEMPTIONS = ""
1415
REPOSITORY = ""
1516
TITLE = ""
1617
TYPE = ""

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ This action can be configured to authenticate with GitHub App Installation or Pe
7979
| `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. |
8080
| `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. |
8181
| `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`. |
82+
| `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. |
8283

8384
### Example workflows
8485

dependabot_file.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,11 @@ def make_dependabot_config(ecosystem, group_dependencies, indent) -> str:
3232

3333

3434
def build_dependabot_file(
35-
repo, group_dependencies, exempt_ecosystems, existing_config
35+
repo,
36+
group_dependencies,
37+
exempt_ecosystems,
38+
repo_specific_exemptions,
39+
existing_config,
3640
) -> str | None:
3741
"""
3842
Build the dependabot.yml file for a repo based on the repo contents
@@ -41,6 +45,7 @@ def build_dependabot_file(
4145
repo: the repository to build the dependabot.yml file for
4246
group_dependencies: whether to group dependencies in the dependabot.yml file
4347
exempt_ecosystems: the list of ecosystems to ignore
48+
repo_specific_exemptions: the list of ecosystems to ignore for a specific repo
4449
existing_config: the existing dependabot configuration file or None if it doesn't exist
4550
4651
Returns:
@@ -83,6 +88,13 @@ def build_dependabot_file(
8388

8489
add_existing_ecosystem_to_exempt_list(exempt_ecosystems, existing_config)
8590

91+
# If there are repository specific exemptions,
92+
# overwrite the global exemptions for this repo only
93+
if repo_specific_exemptions and repo.full_name in repo_specific_exemptions:
94+
exempt_ecosystems = []
95+
for ecosystem in repo_specific_exemptions[repo.full_name]:
96+
exempt_ecosystems.append(ecosystem)
97+
8698
package_managers = {
8799
"bundler": ["Gemfile", "Gemfile.lock"],
88100
"npm": ["package.json", "package-lock.json", "yarn.lock"],

env.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,53 @@ def get_int_env_var(env_var_name: str) -> int | None:
4343
return None
4444

4545

46+
def parse_repo_specific_exemptions(repo_specific_exemptions_str: str) -> dict:
47+
"""Parse the REPO_SPECIFIC_EXEMPTIONS environment variable into a dictionary.
48+
49+
Args:
50+
repo_specific_exemptions_str: The REPO_SPECIFIC_EXEMPTIONS environment variable as a string.
51+
52+
Returns:
53+
A dictionary where keys are repository names and values are lists of exempt ecosystems.
54+
"""
55+
exemptions_dict = {}
56+
if repo_specific_exemptions_str:
57+
# if repo_specific_exemptions_str doesn't have a ; and : character, it's not valid
58+
separators = [";", ":"]
59+
if not all(sep in repo_specific_exemptions_str for sep in separators):
60+
raise ValueError(
61+
"REPO_SPECIFIC_EXEMPTIONS environment variable not formatted correctly"
62+
)
63+
exemptions_list = repo_specific_exemptions_str.split(";")
64+
for exemption in exemptions_list:
65+
if (
66+
exemption == ""
67+
): # Account for final ; in the repo_specific_exemptions_str
68+
continue
69+
repo, ecosystems = exemption.split(":")
70+
for ecosystem in ecosystems.split(","):
71+
if ecosystem not in [
72+
"bundler",
73+
"cargo",
74+
"composer",
75+
"docker",
76+
"github-actions",
77+
"gomod",
78+
"mix",
79+
"npm",
80+
"nuget",
81+
"pip",
82+
"terraform",
83+
]:
84+
raise ValueError(
85+
"REPO_SPECIFIC_EXEMPTIONS environment variable not formatted correctly. Unrecognized package-ecosystem."
86+
)
87+
exemptions_dict[repo.strip()] = [
88+
ecosystem.strip() for ecosystem in ecosystems.split(",")
89+
]
90+
return exemptions_dict
91+
92+
4693
def get_env_vars(test: bool = False) -> tuple[
4794
str | None,
4895
list[str],
@@ -65,6 +112,7 @@ def get_env_vars(test: bool = False) -> tuple[
65112
bool | None,
66113
list[str],
67114
bool | None,
115+
dict,
68116
]:
69117
"""
70118
Get the environment variables for use in the action.
@@ -93,6 +141,7 @@ def get_env_vars(test: bool = False) -> tuple[
93141
enable_security_updates (bool): Whether to enable security updates in target repositories
94142
exempt_ecosystems_list (list[str]): A list of package ecosystems to exempt from the action
95143
update_existing (bool): Whether to update existing dependabot configuration files
144+
repo_specific_exemptions (dict): A dictionary of per repository ecosystem exemptions
96145
"""
97146

98147
if not test:
@@ -234,6 +283,11 @@ def get_env_vars(test: bool = False) -> tuple[
234283

235284
update_existing = get_bool_env_var("UPDATE_EXISTING")
236285

286+
repo_specific_exemptions_str = os.getenv("REPO_SPECIFIC_EXEMPTIONS", "")
287+
repo_specific_exemptions = parse_repo_specific_exemptions(
288+
repo_specific_exemptions_str
289+
)
290+
237291
return (
238292
organization,
239293
repositories_list,
@@ -256,4 +310,5 @@ def get_env_vars(test: bool = False) -> tuple[
256310
enable_security_updates_bool,
257311
exempt_ecosystems_list,
258312
update_existing,
313+
repo_specific_exemptions,
259314
)

evergreen.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ def main(): # pragma: no cover
3636
enable_security_updates,
3737
exempt_ecosystems,
3838
update_existing,
39+
repo_specific_exemptions,
3940
) = env.get_env_vars()
4041

4142
# Auth to GitHub.com or GHE
@@ -96,7 +97,11 @@ def main(): # pragma: no cover
9697
print("Checking " + repo.full_name + " for compatible package managers")
9798
# Try to detect package managers and build a dependabot file
9899
dependabot_file = build_dependabot_file(
99-
repo, group_dependencies, exempt_ecosystems, existing_config
100+
repo,
101+
group_dependencies,
102+
exempt_ecosystems,
103+
repo_specific_exemptions,
104+
existing_config,
100105
)
101106

102107
if dependabot_file is None:

test_dependabot_file.py

Lines changed: 29 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ def test_not_found_error(self):
2020
response.status_code = 404
2121
repo.file_contents.side_effect = github3.exceptions.NotFoundError(resp=response)
2222

23-
result = build_dependabot_file(repo, False, [], None)
23+
result = build_dependabot_file(repo, False, [], {}, None)
2424
self.assertEqual(result, None)
2525

2626
def test_build_dependabot_file_with_bundler(self):
@@ -38,7 +38,7 @@ def test_build_dependabot_file_with_bundler(self):
3838
schedule:
3939
interval: 'weekly'
4040
"""
41-
result = build_dependabot_file(repo, False, [], None)
41+
result = build_dependabot_file(repo, False, [], {}, None)
4242
self.assertEqual(result, expected_result)
4343

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

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

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

101101
def test_build_dependabot_file_with_npm(self):
@@ -113,7 +113,7 @@ def test_build_dependabot_file_with_npm(self):
113113
schedule:
114114
interval: 'weekly'
115115
"""
116-
result = build_dependabot_file(repo, False, [], None)
116+
result = build_dependabot_file(repo, False, [], {}, None)
117117
self.assertEqual(result, expected_result)
118118

119119
def test_build_dependabot_file_with_pip(self):
@@ -137,7 +137,7 @@ def test_build_dependabot_file_with_pip(self):
137137
schedule:
138138
interval: 'weekly'
139139
"""
140-
result = build_dependabot_file(repo, False, [], None)
140+
result = build_dependabot_file(repo, False, [], {}, None)
141141
self.assertEqual(result, expected_result)
142142

143143
def test_build_dependabot_file_with_cargo(self):
@@ -158,7 +158,7 @@ def test_build_dependabot_file_with_cargo(self):
158158
schedule:
159159
interval: 'weekly'
160160
"""
161-
result = build_dependabot_file(repo, False, [], None)
161+
result = build_dependabot_file(repo, False, [], {}, None)
162162
self.assertEqual(result, expected_result)
163163

164164
def test_build_dependabot_file_with_gomod(self):
@@ -174,7 +174,7 @@ def test_build_dependabot_file_with_gomod(self):
174174
schedule:
175175
interval: 'weekly'
176176
"""
177-
result = build_dependabot_file(repo, False, [], None)
177+
result = build_dependabot_file(repo, False, [], {}, None)
178178
self.assertEqual(result, expected_result)
179179

180180
def test_build_dependabot_file_with_composer(self):
@@ -195,7 +195,7 @@ def test_build_dependabot_file_with_composer(self):
195195
schedule:
196196
interval: 'weekly'
197197
"""
198-
result = build_dependabot_file(repo, False, [], None)
198+
result = build_dependabot_file(repo, False, [], {}, None)
199199
self.assertEqual(result, expected_result)
200200

201201
def test_build_dependabot_file_with_hex(self):
@@ -216,7 +216,7 @@ def test_build_dependabot_file_with_hex(self):
216216
schedule:
217217
interval: 'weekly'
218218
"""
219-
result = build_dependabot_file(repo, False, [], None)
219+
result = build_dependabot_file(repo, False, [], {}, None)
220220
self.assertEqual(result, expected_result)
221221

222222
def test_build_dependabot_file_with_nuget(self):
@@ -232,7 +232,7 @@ def test_build_dependabot_file_with_nuget(self):
232232
schedule:
233233
interval: 'weekly'
234234
"""
235-
result = build_dependabot_file(repo, False, [], None)
235+
result = build_dependabot_file(repo, False, [], {}, None)
236236
self.assertEqual(result, expected_result)
237237

238238
def test_build_dependabot_file_with_docker(self):
@@ -248,7 +248,7 @@ def test_build_dependabot_file_with_docker(self):
248248
schedule:
249249
interval: 'weekly'
250250
"""
251-
result = build_dependabot_file(repo, False, [], None)
251+
result = build_dependabot_file(repo, False, [], {}, None)
252252
self.assertEqual(result, expected_result)
253253

254254
def test_build_dependabot_file_with_terraform_with_files(self):
@@ -269,7 +269,7 @@ def test_build_dependabot_file_with_terraform_with_files(self):
269269
schedule:
270270
interval: 'weekly'
271271
"""
272-
result = build_dependabot_file(repo, False, [], None)
272+
result = build_dependabot_file(repo, False, [], {}, None)
273273
self.assertEqual(result, expected_result)
274274

275275
def test_build_dependabot_file_with_terraform_without_files(self):
@@ -281,7 +281,7 @@ def test_build_dependabot_file_with_terraform_without_files(self):
281281

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

287287
# Test empty repository
@@ -290,7 +290,7 @@ def test_build_dependabot_file_with_terraform_without_files(self):
290290
repo.directory_contents.side_effect = github3.exceptions.NotFoundError(
291291
resp=response
292292
)
293-
result = build_dependabot_file(repo, False, [], None)
293+
result = build_dependabot_file(repo, False, [], {}, None)
294294
self.assertIsNone(result)
295295

296296
def test_build_dependabot_file_with_github_actions(self):
@@ -311,7 +311,7 @@ def test_build_dependabot_file_with_github_actions(self):
311311
schedule:
312312
interval: 'weekly'
313313
"""
314-
result = build_dependabot_file(repo, False, [], None)
314+
result = build_dependabot_file(repo, False, [], None, None)
315315
self.assertEqual(result, expected_result)
316316

317317
def test_build_dependabot_file_with_github_actions_without_files(self):
@@ -324,7 +324,7 @@ def test_build_dependabot_file_with_github_actions_without_files(self):
324324
resp=response
325325
)
326326

327-
result = build_dependabot_file(repo, False, [], None)
327+
result = build_dependabot_file(repo, False, [], None, None)
328328
self.assertEqual(result, None)
329329

330330
def test_build_dependabot_file_with_groups(self):
@@ -345,15 +345,24 @@ def test_build_dependabot_file_with_groups(self):
345345
development-dependencies:
346346
dependency-type: 'development'
347347
"""
348-
result = build_dependabot_file(repo, True, [], None)
348+
result = build_dependabot_file(repo, True, [], {}, None)
349349
self.assertEqual(result, expected_result)
350350

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

356-
result = build_dependabot_file(repo, False, ["docker"], None)
356+
result = build_dependabot_file(repo, False, ["docker"], {}, None)
357+
self.assertEqual(result, None)
358+
359+
def test_build_dependabot_file_with_repo_specific_exempt_ecosystems(self):
360+
"""Test that the dependabot.yml file is built correctly with exempted ecosystems"""
361+
repo = MagicMock()
362+
repo.full_name = "test/test"
363+
repo.file_contents.side_effect = lambda filename: filename == "Dockerfile"
364+
365+
result = build_dependabot_file(repo, False, [], {"test/test": ["docker"]}, None)
357366
self.assertEqual(result, None)
358367

359368
def test_add_existing_ecosystem_to_exempt_list(self):

0 commit comments

Comments
 (0)