Skip to content

Commit 97ab2c0

Browse files
authored
ci(deps): group github/codeql-action Dependabot bumps (#2390)
Problem: Dependabot opened three separate PRs bumping one `github/codeql-action` step each (init, autobuild, analyze). codeql-action now refuses to run unless every step in the workflow is on the same version, so none of the three could pass CI alone -- init persists a configuration tagged with its own version, and the next step that runs at a different version refuses to read it: autobuild on two of the three, analyze on the third. Fix: group all `github/codeql-action/*` bumps under the github-actions ecosystem, so Dependabot opens one PR that bumps every step together. Test: tests/project/test_actions_pinned_1968.py's new TestDependabotGroupsTheCodeQLSteps checks the group sits under the right ecosystem and under no other, that its pattern claims every codeql-action step the workflows use and nothing else, and that those steps are pinned to one release. 7 of 8 mutations tried -- removing the group, a typo'd pattern, widening it to "*", repointing it, moving it under the wrong ecosystem, renaming it, and unpinning one step from the rest -- fail at least one of these tests; the full set is also exercised against a bare origin/main checkout.
1 parent 7ad0efb commit 97ab2c0

2 files changed

Lines changed: 110 additions & 0 deletions

File tree

‎.github/dependabot.yml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,7 @@ updates:
2626
directory: "/" # Dependabot reads .github/workflows from the repository root
2727
schedule:
2828
interval: "weekly"
29+
groups: # codeql-action now refuses to run unless every step in a workflow
30+
codeql-action: # (init, autobuild, analyze) is on the same version (#2384-#2386)
31+
patterns:
32+
- "github/codeql-action/*"

‎tests/project/test_actions_pinned_1968.py‎

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@
1616
composite action under :file:`.github`, since YAML drops the comment.
1717
* :class:`TestDependabotUpdatesTheActions` checks :file:`.github/dependabot.yml`
1818
watches the ``github-actions`` ecosystem from the repository root.
19+
* :class:`TestDependabotGroupsTheCodeQLSteps` checks that entry groups the
20+
``github/codeql-action`` steps, which refuse to run at mixed versions
21+
(#2384-#2386), and groups nothing else.
1922
* :class:`TestDependabotCoversEveryManifest` checks the owner's ruling on #1968:
2023
``pip`` over the root, :file:`docs` and :file:`examples/benchmark`, and
2124
neither :file:`conda` nor a ``docker`` entry, as Dependabot cannot update
@@ -62,6 +65,12 @@
6265
PYUP = ROOT / '.pyup.yml'
6366
#: The directories the ``pip`` entry watches, in the owner's ruling on #1968.
6467
PIP_DIRECTORIES = ['/', '/docs', '/examples/benchmark']
68+
#: The ecosystem whose dependencies are the ``uses:`` references.
69+
ACTIONS = 'github-actions'
70+
#: The group holding the CodeQL Action's steps (#2384-#2386), under that ecosystem.
71+
CODEQL_GROUP = 'codeql-action'
72+
#: The action whose steps that group holds, without the step's sub-path.
73+
CODEQL = 'github/codeql-action'
6574

6675

6776
def updates() -> 'dict[str, dict[str, Any]]':
@@ -83,6 +92,45 @@ def files() -> 'list[pathlib.Path]':
8392
return found
8493

8594

95+
def groups(ecosystem: 'str') -> 'dict[str, Any]':
96+
"""The ``groups`` an entry declares, by group name, empty where it declares none."""
97+
return updates()[ecosystem].get('groups') or {}
98+
99+
100+
def references() -> 'set[str]':
101+
"""Every non-local action a ``uses:`` under :file:`.github` names, without its commit.
102+
103+
These are the dependency names Dependabot's ``github-actions`` ecosystem
104+
matches a group's ``patterns`` against, the step's sub-path included:
105+
``github/codeql-action/init`` rather than ``github/codeql-action``.
106+
107+
"""
108+
found = set()
109+
for path in files():
110+
for match in USES.finditer(path.read_text(encoding='utf-8')):
111+
ref = match['ref'].strip('\'"')
112+
if not ref.startswith(('./', 'docker://')):
113+
found.add(ref.partition('@')[0])
114+
return found
115+
116+
117+
def grouped(name: 'str', patterns: 'list[str]') -> 'list[str]':
118+
"""Which of ``patterns`` claim the dependency ``name``.
119+
120+
dependabot-core matches a group's pattern with ``WildcardMatcher.match?``,
121+
which folds case on both sides and turns each ``*`` into ``.*``, treating
122+
every other character -- ``?`` and ``[...]`` included -- as literal:
123+
``github/codeql-action/*`` claims ``github/codeql-action/init`` and a bare
124+
``*`` claims every action in the tree, but ``github/codeql-action/[a-z]*``
125+
claims nothing, since dependabot-core reads the brackets as a literal
126+
substring rather than a character class.
127+
128+
"""
129+
return [pattern for pattern in patterns
130+
if re.fullmatch('.*'.join(re.escape(part) for part in pattern.lower().split('*')),
131+
name.lower(), re.S)]
132+
133+
86134
def unpinned(text: 'str') -> 'list[str]':
87135
"""Each non-local ``uses:`` line of ``text`` not pinned to a commit with a version comment."""
88136
bad = []
@@ -158,6 +206,64 @@ def test_it_reads_the_workflows_from_the_root(self) -> None:
158206
self.assertRegex(entry.group(0), r'(?m)^\s*interval:\s*"\w+"') # type: ignore[union-attr]
159207

160208

209+
class TestDependabotGroupsTheCodeQLSteps(unittest.TestCase):
210+
"""The ``github/codeql-action`` steps bump in one pull request (#2384-#2386).
211+
212+
``Initialize CodeQL`` persists a configuration tagged with the version that
213+
wrote it, and a later step of the action refuses to read one a different
214+
version wrote: ``Loaded a configuration file for version '4.39.0', but
215+
running version '4.39.1'.`` So an ungrouped bump of a single step leaves the
216+
``Analyze`` job red whichever step moved -- measured on #2384 and #2386,
217+
where ``Autobuild`` refused and ``Perform CodeQL Analysis`` never ran, and on
218+
#2385, where ``Perform CodeQL Analysis`` refused. The error says so itself:
219+
*If you are using Dependabot to manage dependency updates, you can configure
220+
a dependency group to update all* ``github/codeql-action`` *steps at the same
221+
time.*
222+
223+
The group has to sit under ``github-actions``, since no other ecosystem sees
224+
an action, and its patterns have to claim every step the workflows use and no
225+
other action: a wider pattern batches unrelated bumps into the same pull
226+
request, which is what reviewing one action at a time avoids.
227+
228+
"""
229+
230+
def test_the_actions_entry_declares_the_group(self) -> None:
231+
self.assertIn(CODEQL_GROUP, groups(ACTIONS))
232+
233+
def test_no_other_ecosystem_declares_it(self) -> None:
234+
"""``pip`` reads a manifest, never a ``uses:``, so a group there groups nothing."""
235+
for ecosystem in updates():
236+
if ecosystem != ACTIONS:
237+
with self.subTest(ecosystem=ecosystem):
238+
self.assertNotIn(CODEQL_GROUP, groups(ecosystem))
239+
240+
def test_the_patterns_claim_every_step_the_workflows_use(self) -> None:
241+
"""A pattern that has gone stale, by a typo or by a step added beside it."""
242+
patterns = groups(ACTIONS)[CODEQL_GROUP]['patterns']
243+
steps = sorted(name for name in references() if name.startswith(f'{CODEQL}/'))
244+
self.assertTrue(steps, f'no workflow uses {CODEQL}, so the group holds nothing')
245+
for name in steps:
246+
with self.subTest(action=name):
247+
self.assertTrue(grouped(name, patterns))
248+
249+
def test_the_patterns_claim_nothing_else(self) -> None:
250+
patterns = groups(ACTIONS)[CODEQL_GROUP]['patterns']
251+
for name in sorted(references()):
252+
if not name.startswith(f'{CODEQL}/'):
253+
with self.subTest(action=name):
254+
self.assertEqual(grouped(name, patterns), [])
255+
256+
def test_every_step_is_pinned_to_one_release(self) -> None:
257+
"""What the group is there to keep true, read from the pins themselves."""
258+
pins = {}
259+
for path in files():
260+
for match in _workflows.PINNED.finditer(path.read_text(encoding='utf-8')):
261+
if match['action'].startswith(f'{CODEQL}/'):
262+
pins[match['action']] = (match['sha'], match['version'])
263+
self.assertEqual(sorted(pins), sorted(name for name in references()
264+
if name.startswith(f'{CODEQL}/')))
265+
self.assertEqual(len(set(pins.values())), 1, f'the steps disagree on a release: {pins}')
266+
161267

162268
class TestDependabotCoversEveryManifest(unittest.TestCase):
163269
"""Dependabot watches the docs and benchmark pins, and nothing it cannot update."""

0 commit comments

Comments
 (0)