Skip to content

Commit 0f11c4f

Browse files
authored
Merge pull request #494 from github-community-projects/support-cooldown-config
feat: support cooldown for new versions
2 parents ac9b9e8 + d6cb1b4 commit 0f11c4f

File tree

5 files changed

+433
-20
lines changed

5 files changed

+433
-20
lines changed

README.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,48 @@ updates:
187187
- "new"
188188
```
189189
190+
### Cooldown configuration
191+
192+
Dependabot supports a [cooldown](https://docs.github.com/en/code-security/dependabot/working-with-dependabot/dependabot-options-reference#cooldown-) feature that delays version update pull requests for a configurable number of days after a new version is released. This is useful to avoid updating to freshly-released versions that may contain issues.
193+
194+
> **Note:** Cooldown only applies to **version updates**, not security updates. Security updates are always created immediately.
195+
196+
To configure cooldown, add a `cooldown` key to the `DEPENDABOT_CONFIG_FILE`:
197+
198+
```yaml
199+
# Cooldown configuration (applied globally to all ecosystems)
200+
cooldown:
201+
default-days: 3
202+
semver-major-days: 7
203+
semver-minor-days: 3
204+
semver-patch-days: 1
205+
include:
206+
- 'lodash'
207+
- 'react*'
208+
exclude:
209+
- 'critical-package'
210+
211+
# Private registry configurations (optional, existing feature)
212+
npm:
213+
type: "npm"
214+
url: "https://yourprivateregistry/npm/"
215+
username: "${{secrets.username}}"
216+
password: "${{secrets.password}}"
217+
```
218+
219+
#### Cooldown options
220+
221+
| Parameter | Description |
222+
| --- | --- |
223+
| `default-days` | Default cooldown period in days for dependencies without specific rules. |
224+
| `semver-major-days` | Cooldown period in days for major version updates. |
225+
| `semver-minor-days` | Cooldown period in days for minor version updates. |
226+
| `semver-patch-days` | Cooldown period in days for patch version updates. |
227+
| `include` | List of dependency names to apply cooldown to (up to 150 items, supports `*` wildcards). |
228+
| `exclude` | List of dependency names excluded from cooldown (up to 150 items, supports `*` wildcards). |
229+
230+
At least one of the `*-days` parameters must be specified. All day values must be integers between 1 and 90.
231+
190232
### Example workflows
191233
192234
#### Basic

dependabot_file.py

Lines changed: 93 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import base64
44
import copy
55
import io
6+
from collections.abc import Mapping
67

78
import github3
89
import ruamel.yaml
@@ -19,6 +20,75 @@
1920
stream = io.StringIO()
2021

2122

23+
COOLDOWN_DAYS_KEYS_ORDERED = (
24+
"default-days",
25+
"semver-major-days",
26+
"semver-minor-days",
27+
"semver-patch-days",
28+
)
29+
VALID_COOLDOWN_DAYS_KEYS = frozenset(COOLDOWN_DAYS_KEYS_ORDERED)
30+
VALID_COOLDOWN_KEYS = VALID_COOLDOWN_DAYS_KEYS | {"include", "exclude"}
31+
MAX_COOLDOWN_LIST_ITEMS = 150
32+
MIN_COOLDOWN_DAYS = 1
33+
MAX_COOLDOWN_DAYS = 90
34+
35+
36+
def validate_cooldown_config(cooldown):
37+
"""
38+
Validate the cooldown configuration from the dependabot config file.
39+
40+
Args:
41+
cooldown: dict with cooldown configuration
42+
43+
Raises:
44+
ValueError: if the cooldown configuration is invalid
45+
"""
46+
if not isinstance(cooldown, Mapping):
47+
raise ValueError("Cooldown configuration must be a mapping")
48+
49+
unknown_keys = set(cooldown.keys()) - VALID_COOLDOWN_KEYS
50+
if unknown_keys:
51+
raise ValueError(
52+
f"Unknown cooldown configuration keys: {', '.join(sorted(unknown_keys))}"
53+
)
54+
55+
has_days = False
56+
for key in VALID_COOLDOWN_DAYS_KEYS:
57+
if key in cooldown:
58+
value = cooldown[key]
59+
if not isinstance(value, int) or isinstance(value, bool):
60+
raise ValueError(
61+
f"Cooldown '{key}' must be an integer between "
62+
f"{MIN_COOLDOWN_DAYS} and {MAX_COOLDOWN_DAYS}, "
63+
f"got {type(value).__name__}"
64+
)
65+
if value < MIN_COOLDOWN_DAYS or value > MAX_COOLDOWN_DAYS:
66+
raise ValueError(
67+
f"Cooldown '{key}' must be between "
68+
f"{MIN_COOLDOWN_DAYS} and {MAX_COOLDOWN_DAYS}"
69+
)
70+
has_days = True
71+
72+
if not has_days:
73+
raise ValueError(
74+
"Cooldown configuration must include at least one of: "
75+
+ ", ".join(sorted(VALID_COOLDOWN_DAYS_KEYS))
76+
)
77+
78+
for list_key in ("include", "exclude"):
79+
if list_key in cooldown:
80+
items = cooldown[list_key]
81+
if not isinstance(items, list):
82+
raise ValueError(f"Cooldown '{list_key}' must be a list")
83+
if len(items) > MAX_COOLDOWN_LIST_ITEMS:
84+
raise ValueError(
85+
f"Cooldown '{list_key}' must have at most {MAX_COOLDOWN_LIST_ITEMS} items"
86+
)
87+
for item in items:
88+
if not isinstance(item, str):
89+
raise ValueError(f"Cooldown '{list_key}' items must be strings")
90+
91+
2292
def make_dependabot_config(
2393
ecosystem,
2494
group_dependencies,
@@ -27,9 +97,12 @@ def make_dependabot_config(
2797
labels,
2898
dependabot_config,
2999
extra_dependabot_config,
30-
) -> str:
100+
cooldown=None,
101+
) -> None:
31102
"""
32-
Make the dependabot configuration for a specific package ecosystem
103+
Make the dependabot configuration for a specific package ecosystem.
104+
105+
Mutates dependabot_config in place by appending an update entry.
33106
34107
Args:
35108
ecosystem: the package ecosystem to make the dependabot configuration for
@@ -39,9 +112,7 @@ def make_dependabot_config(
39112
labels: the list of labels to be added to dependabot configuration
40113
dependabot_config: extra dependabot configs
41114
extra_dependabot_config: File with the configuration to add dependabot configs (ex: private registries)
42-
43-
Returns:
44-
str: the dependabot configuration for the package ecosystem
115+
cooldown: optional cooldown configuration dict to delay version update PRs
45116
"""
46117

47118
dependabot_config["updates"].append(
@@ -97,7 +168,17 @@ def make_dependabot_config(
97168
}
98169
)
99170

100-
return yaml.dump(dependabot_config, stream)
171+
if cooldown:
172+
cooldown_config = {}
173+
for key in COOLDOWN_DAYS_KEYS_ORDERED:
174+
if key in cooldown:
175+
cooldown_config[key] = cooldown[key]
176+
for list_key in ("include", "exclude"):
177+
if list_key in cooldown:
178+
cooldown_config[list_key] = [
179+
SingleQuotedScalarString(item) for item in cooldown[list_key]
180+
]
181+
dependabot_config["updates"][-1].update({"cooldown": cooldown_config})
101182

102183

103184
def build_dependabot_file(
@@ -110,6 +191,7 @@ def build_dependabot_file(
110191
schedule_day,
111192
labels,
112193
extra_dependabot_config,
194+
cooldown=None,
113195
) -> str | None:
114196
"""
115197
Build the dependabot.yml file for a repo based on the repo contents
@@ -124,6 +206,7 @@ def build_dependabot_file(
124206
schedule_day: the day of the week to run dependabot ex: "monday" if schedule is "daily"
125207
labels: the list of labels to be added to dependabot configuration
126208
extra_dependabot_config: File with the configuration to add dependabot configs (ex: private registries)
209+
cooldown: optional cooldown configuration dict to delay version update PRs
127210
128211
Returns:
129212
str: the dependabot.yml file for the repo
@@ -203,6 +286,7 @@ def build_dependabot_file(
203286
labels,
204287
dependabot_file,
205288
extra_dependabot_config,
289+
cooldown,
206290
)
207291
break
208292
except OptionalFileNotFoundError:
@@ -224,6 +308,7 @@ def build_dependabot_file(
224308
labels,
225309
dependabot_file,
226310
extra_dependabot_config,
311+
cooldown,
227312
)
228313
break
229314
except github3.exceptions.NotFoundError:
@@ -243,6 +328,7 @@ def build_dependabot_file(
243328
labels,
244329
dependabot_file,
245330
extra_dependabot_config,
331+
cooldown,
246332
)
247333
break
248334
except github3.exceptions.NotFoundError:
@@ -262,6 +348,7 @@ def build_dependabot_file(
262348
labels,
263349
dependabot_file,
264350
extra_dependabot_config,
351+
cooldown,
265352
)
266353
break
267354
except github3.exceptions.NotFoundError:

evergreen.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
import github3
1111
import requests
1212
import ruamel.yaml
13-
from dependabot_file import build_dependabot_file
13+
from dependabot_file import build_dependabot_file, validate_cooldown_config
1414
from exceptions import OptionalFileNotFoundError, check_optional_file
1515

1616

@@ -162,6 +162,16 @@ def main(): # pragma: no cover
162162
# If no dependabot configuration file is present set the variable empty
163163
extra_dependabot_config = None
164164

165+
# Extract cooldown config if present (it's not a registry/ecosystem key)
166+
cooldown = None
167+
if extra_dependabot_config and "cooldown" in extra_dependabot_config:
168+
cooldown = extra_dependabot_config.pop("cooldown")
169+
try:
170+
validate_cooldown_config(cooldown)
171+
except ValueError as e:
172+
print(f"Invalid cooldown configuration: {e}")
173+
cooldown = None
174+
165175
print(f"Checking {repo.full_name} for compatible package managers")
166176
# Try to detect package managers and build a dependabot file
167177
dependabot_file = build_dependabot_file(
@@ -174,6 +184,7 @@ def main(): # pragma: no cover
174184
schedule_day,
175185
labels,
176186
extra_dependabot_config,
187+
cooldown,
177188
)
178189

179190
yaml = ruamel.yaml.YAML()

test_cooldown.py

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
"""Tests for the cooldown validation in dependabot_file.py."""
2+
3+
import unittest
4+
5+
from dependabot_file import validate_cooldown_config
6+
from ruamel.yaml import YAML
7+
8+
9+
class TestValidateCooldownConfig(unittest.TestCase):
10+
"""Test the validate_cooldown_config function."""
11+
12+
def test_valid_default_days_only(self):
13+
"""Test valid cooldown with just default-days"""
14+
validate_cooldown_config({"default-days": 3})
15+
16+
def test_valid_ruamel_commented_map(self):
17+
"""Test that ruamel.yaml CommentedMap is accepted as a valid mapping"""
18+
yaml = YAML()
19+
cooldown = yaml.load(b"default-days: 5\nsemver-major-days: 14\n")
20+
validate_cooldown_config(cooldown)
21+
22+
def test_valid_all_days(self):
23+
"""Test valid cooldown with all day parameters"""
24+
validate_cooldown_config(
25+
{
26+
"default-days": 3,
27+
"semver-major-days": 7,
28+
"semver-minor-days": 3,
29+
"semver-patch-days": 1,
30+
}
31+
)
32+
33+
def test_valid_with_include_exclude(self):
34+
"""Test valid cooldown with include and exclude lists"""
35+
validate_cooldown_config(
36+
{
37+
"default-days": 5,
38+
"include": ["lodash", "react*"],
39+
"exclude": ["critical-pkg"],
40+
}
41+
)
42+
43+
def test_valid_zero_days(self):
44+
"""Test that zero is below the minimum and raises ValueError"""
45+
with self.assertRaises(ValueError):
46+
validate_cooldown_config({"default-days": 0})
47+
48+
def test_valid_boundary_min(self):
49+
"""Test that 1 day is the minimum valid value"""
50+
validate_cooldown_config({"default-days": 1})
51+
52+
def test_valid_boundary_max(self):
53+
"""Test that 90 days is the maximum valid value"""
54+
validate_cooldown_config({"default-days": 90})
55+
56+
def test_invalid_not_a_dict(self):
57+
"""Test that non-dict cooldown raises ValueError"""
58+
with self.assertRaises(ValueError):
59+
validate_cooldown_config("not a dict")
60+
61+
def test_invalid_no_days_keys(self):
62+
"""Test that cooldown without any days key raises ValueError"""
63+
with self.assertRaises(ValueError):
64+
validate_cooldown_config({"include": ["lodash"]})
65+
66+
def test_invalid_negative_days(self):
67+
"""Test that negative days raises ValueError"""
68+
with self.assertRaises(ValueError):
69+
validate_cooldown_config({"default-days": -1})
70+
71+
def test_invalid_days_exceed_max(self):
72+
"""Test that days above 90 raises ValueError"""
73+
with self.assertRaises(ValueError):
74+
validate_cooldown_config({"default-days": 91})
75+
76+
def test_invalid_days_not_int(self):
77+
"""Test that non-integer days raises ValueError"""
78+
with self.assertRaises(ValueError):
79+
validate_cooldown_config({"default-days": "three"})
80+
81+
def test_invalid_days_bool(self):
82+
"""Test that boolean days raises ValueError"""
83+
with self.assertRaises(ValueError):
84+
validate_cooldown_config({"default-days": True})
85+
86+
def test_invalid_unknown_key(self):
87+
"""Test that unknown keys raise ValueError"""
88+
with self.assertRaises(ValueError):
89+
validate_cooldown_config({"default-days": 3, "unknown-key": 1})
90+
91+
def test_invalid_include_not_list(self):
92+
"""Test that non-list include raises ValueError"""
93+
with self.assertRaises(ValueError):
94+
validate_cooldown_config({"default-days": 3, "include": "lodash"})
95+
96+
def test_invalid_include_item_not_string(self):
97+
"""Test that non-string include items raise ValueError"""
98+
with self.assertRaises(ValueError):
99+
validate_cooldown_config({"default-days": 3, "include": [123]})
100+
101+
def test_invalid_include_too_many_items(self):
102+
"""Test that include with more than 150 items raises ValueError"""
103+
with self.assertRaises(ValueError):
104+
validate_cooldown_config(
105+
{
106+
"default-days": 3,
107+
"include": [f"pkg-{i}" for i in range(151)],
108+
}
109+
)
110+
111+
def test_invalid_exclude_too_many_items(self):
112+
"""Test that exclude with more than 150 items raises ValueError"""
113+
with self.assertRaises(ValueError):
114+
validate_cooldown_config(
115+
{
116+
"default-days": 3,
117+
"exclude": [f"pkg-{i}" for i in range(151)],
118+
}
119+
)
120+
121+
122+
if __name__ == "__main__":
123+
unittest.main()

0 commit comments

Comments
 (0)