Skip to content

Commit a9bd9eb

Browse files
committed
ci(deps): group github/codeql-action Dependabot bumps (#2384, #2385, #2386)
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 0c9335e commit a9bd9eb

2 files changed

Lines changed: 107 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: 103 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
@@ -31,6 +34,7 @@
3134

3235
from __future__ import annotations
3336

37+
import fnmatch
3438
import re
3539
import unittest
3640
from typing import TYPE_CHECKING
@@ -62,6 +66,12 @@
6266
PYUP = ROOT / '.pyup.yml'
6367
#: The directories the ``pip`` entry watches, in the owner's ruling on #1968.
6468
PIP_DIRECTORIES = ['/', '/docs', '/examples/benchmark']
69+
#: The ecosystem whose dependencies are the ``uses:`` references.
70+
ACTIONS = 'github-actions'
71+
#: The group holding the CodeQL Action's steps (#2384-#2386), under that ecosystem.
72+
CODEQL_GROUP = 'codeql-action'
73+
#: The action whose steps that group holds, without the step's sub-path.
74+
CODEQL = 'github/codeql-action'
6575

6676

6777
def updates() -> 'dict[str, dict[str, Any]]':
@@ -83,6 +93,41 @@ def files() -> 'list[pathlib.Path]':
8393
return found
8494

8595

96+
def groups(ecosystem: 'str') -> 'dict[str, Any]':
97+
"""The ``groups`` an entry declares, by group name, empty where it declares none."""
98+
return updates()[ecosystem].get('groups') or {}
99+
100+
101+
def references() -> 'set[str]':
102+
"""Every non-local action a ``uses:`` under :file:`.github` names, without its commit.
103+
104+
These are the dependency names Dependabot's ``github-actions`` ecosystem
105+
matches a group's ``patterns`` against, the step's sub-path included:
106+
``github/codeql-action/init`` rather than ``github/codeql-action``.
107+
108+
"""
109+
found = set()
110+
for path in files():
111+
for match in USES.finditer(path.read_text(encoding='utf-8')):
112+
ref = match['ref'].strip('\'"')
113+
if not ref.startswith(('./', 'docker://')):
114+
found.add(ref.partition('@')[0])
115+
return found
116+
117+
118+
def grouped(name: 'str', patterns: 'list[str]') -> 'list[str]':
119+
"""Which of ``patterns`` claim the dependency ``name``.
120+
121+
dependabot-core matches a group's pattern with
122+
``File.fnmatch?(pattern, name, File::FNM_CASEFOLD)``, so the match folds case
123+
and, without ``FNM_PATHNAME``, a ``*`` crosses a ``/``:
124+
``github/codeql-action/*`` claims ``github/codeql-action/init``, and a bare
125+
``*`` claims every action in the tree.
126+
127+
"""
128+
return [pattern for pattern in patterns if fnmatch.fnmatchcase(name.lower(), pattern.lower())]
129+
130+
86131
def unpinned(text: 'str') -> 'list[str]':
87132
"""Each non-local ``uses:`` line of ``text`` not pinned to a commit with a version comment."""
88133
bad = []
@@ -158,6 +203,64 @@ def test_it_reads_the_workflows_from_the_root(self) -> None:
158203
self.assertRegex(entry.group(0), r'(?m)^\s*interval:\s*"\w+"') # type: ignore[union-attr]
159204

160205

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

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

0 commit comments

Comments
 (0)