Pulse/scripts/tests/test_dependabot_config.py
pulse-triage[bot] 0168dd3be8 fix(ci): freeze reviewed action pins from Dependabot version updates
action_consumer_manifests.json pins each release-consumed action to an exact SHA plus the upstream action.yml sha256, and release_promotion_policy_test.py requires every workflow step to match. Dependabot's github-actions group bumps those SHAs, so every group proposal fails the manifest contract and cannot be repaired offline. Ignore the eight governed actions for version updates and guard the policy against the manifest so pin refreshes stay explicit reviewed work. This resolves the recurring failing actions-minor-patch proposal (#2139).

Change-source: pulse-maintainer
2026-09-23 08:04:21 +01:00

214 lines
7.9 KiB
Python

#!/usr/bin/env python3
"""Guard the low-noise dependency update policy and its covered manifests."""
import json
from pathlib import Path
import unittest
import yaml
ROOT = Path(__file__).resolve().parents[2]
CONFIG = ROOT / ".github" / "dependabot.yml"
SECURITY_SCAN = ROOT / ".github" / "workflows" / "security-scan.yml"
ACTION_MANIFESTS = ROOT / "scripts" / "release_control" / "action_consumer_manifests.json"
def manifest_directories(filename: str) -> list[str]:
"""Return repository-relative Dependabot directories for every manifest."""
directories = set()
for manifest in ROOT.rglob(filename):
relative = manifest.relative_to(ROOT)
if "node_modules" in relative.parts:
continue
parent = relative.parent.as_posix()
directories.add("/" if parent == "." else f"/{parent}")
return sorted(directories)
def dockerfile_directories() -> list[str]:
"""Return every directory containing a Docker build manifest."""
directories = set()
for manifest in ROOT.rglob("Dockerfile*"):
relative = manifest.relative_to(ROOT)
if "node_modules" in relative.parts:
continue
parent = relative.parent.as_posix()
directories.add("/" if parent == "." else f"/{parent}")
return sorted(directories)
class DependabotConfigTest(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.config = yaml.safe_load(CONFIG.read_text(encoding="utf-8"))
cls.updates = {
entry["package-ecosystem"]: entry for entry in cls.config["updates"]
}
def test_covers_every_shipped_dependency_ecosystem(self) -> None:
self.assertEqual(
set(self.updates),
{
"github-actions",
"gomod",
"npm",
"docker",
"docker-compose",
"devcontainers",
},
)
self.assertEqual(self.updates["github-actions"]["directory"], "/")
self.assertEqual(
sorted(self.updates["gomod"]["directories"]),
manifest_directories("go.mod"),
)
self.assertEqual(
sorted(self.updates["npm"]["directories"]),
manifest_directories("package-lock.json"),
)
self.assertEqual(
sorted(self.updates["docker"]["directories"]),
dockerfile_directories(),
)
self.assertEqual(
self.updates["docker-compose"]["directory"],
"/tests/integration",
)
self.assertEqual(self.updates["devcontainers"]["directory"], "/")
def test_updates_are_weekly_staggered_and_bounded(self) -> None:
self.assertEqual(self.config["version"], 2)
times = []
for entry in self.config["updates"]:
schedule = entry["schedule"]
self.assertEqual(schedule["interval"], "weekly")
self.assertEqual(schedule["day"], "tuesday")
self.assertEqual(schedule["timezone"], "Europe/London")
self.assertLessEqual(entry["open-pull-requests-limit"], 3)
self.assertIn("dependencies", entry["labels"])
times.append(schedule["time"])
self.assertEqual(len(times), len(set(times)), "update jobs must stay staggered")
def test_language_updates_group_reviewable_changes(self) -> None:
for ecosystem, version_group, security_group in (
("gomod", "go-minor-patch", "go-security"),
("npm", "npm-minor-patch", "npm-security"),
):
groups = self.updates[ecosystem]["groups"]
self.assertEqual(
groups[version_group],
{
"applies-to": "version-updates",
"patterns": ["*"],
"update-types": ["minor", "patch"],
},
)
self.assertEqual(
groups[security_group],
{"applies-to": "security-updates", "patterns": ["*"]},
)
def test_docker_updates_preserve_governed_tags(self) -> None:
docker = self.updates["docker"]
self.assertEqual(
docker["groups"]["shared-container-images"],
{"group-by": "dependency-name"},
)
ignored = {
item["dependency-name"]: set(item["update-types"])
for item in docker["ignore"]
}
all_semver = {
"version-update:semver-major",
"version-update:semver-minor",
"version-update:semver-patch",
}
self.assertEqual(set(ignored), {"node", "golang", "alpine"})
self.assertTrue(all(types == all_semver for types in ignored.values()))
compose_ignored = self.updates["docker-compose"]["ignore"]
self.assertEqual(
compose_ignored,
[
{
"dependency-name": "alpine",
"update-types": sorted(all_semver),
}
],
)
def test_npm_updates_preserve_browser_runtime_parity(self) -> None:
all_semver = {
"version-update:semver-major",
"version-update:semver-minor",
"version-update:semver-patch",
}
ignored = {
item["dependency-name"]: set(item["update-types"])
for item in self.updates["npm"]["ignore"]
}
self.assertEqual(
set(ignored),
{"playwright", "playwright-core", "@playwright/test", "typescript"},
)
for browser_runtime in ("playwright", "playwright-core", "@playwright/test"):
self.assertEqual(ignored[browser_runtime], all_semver)
# TypeScript majors must land with the @typescript-eslint peer range
# (">=4.8.4 <6.1.0"), so keep them as explicit work.
self.assertEqual(
ignored["typescript"],
{"version-update:semver-major"},
)
def test_github_actions_updates_preserve_reviewed_action_pins(self) -> None:
# action_consumer_manifests.json pins each release-consumed action to an
# exact SHA plus the upstream action.yml sha256, and
# release_promotion_policy_test.py requires every workflow step to match
# it. Refreshing a pin needs the upstream bytes through the reviewed
# pin-refresh route, so Dependabot must not propose version updates the
# manifest contract would reject.
manifests = json.loads(ACTION_MANIFESTS.read_text(encoding="utf-8"))
ignored = {
item["dependency-name"]
for item in self.updates["github-actions"]["ignore"]
}
self.assertEqual(ignored, set(manifests))
def test_weekly_scan_covers_the_same_lockfiles(self) -> None:
workflow = yaml.safe_load(SECURITY_SCAN.read_text(encoding="utf-8"))
jobs = workflow["jobs"]
self.assertEqual(
set(jobs["govulncheck"]["strategy"]["matrix"]["directory"]),
{
"." if path == "/" else path.removeprefix("/")
for path in manifest_directories("go.mod")
},
)
npm_sets = jobs["npm-audit"]["strategy"]["matrix"]["include"]
self.assertEqual(
{item["directory"] for item in npm_sets},
{
"." if path == "/" else path.removeprefix("/")
for path in manifest_directories("package-lock.json")
},
)
scan_steps = jobs["npm-audit"]["steps"]
audit_commands = [
step["run"]
for step in scan_steps
if step.get("id") in {"audit-complete", "audit-production"}
]
self.assertEqual(
audit_commands,
[
'bash "$GITHUB_WORKSPACE/scripts/npm-audit-retry.sh" '
"all --package-lock-only",
'bash "$GITHUB_WORKSPACE/scripts/npm-audit-retry.sh" '
"production --package-lock-only",
],
)
if __name__ == "__main__":
unittest.main()