Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/cowork-auto-pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ jobs:
# without this step every run failed with "not a git repository" and no
# PR was ever opened (fleet-wide defect: 11/11 seeded copies lacked it).
- name: Check out the pushed branch
uses: actions/checkout@v4
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
ref: ${{ github.ref_name }}
fetch-depth: 0
Expand Down
85 changes: 85 additions & 0 deletions tests/test_ci_hygiene.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
"""CI hygiene regression tests.

Ensures workflow files follow security best practices:
- All GitHub Actions are SHA-pinned (no mutable tags like @v4)
- No silent-failure traps (|| true on validation steps)
"""

from __future__ import annotations

import pytest
import re
from pathlib import Path

REPO_ROOT = Path(__file__).resolve().parent.parent
WORKFLOWS_DIR = REPO_ROOT / ".github" / "workflows"

# Pattern: uses: OWNER/ACTION@REF
# SHA-pinned refs are exactly 40 hex chars.
# Mutable tags look like @v4, @v4.2.2, @main, @release/v1, etc.
USES_PATTERN = re.compile(r"uses:\s*([^@\s]+)@(\S+)")
SHA_PATTERN = re.compile(r"^[0-9a-f]{40}$")

# Local/composite actions (e.g. ./.github/actions/foo) don't need SHA pins.
LOCAL_ACTION_PREFIX = "./"


class TestWorkflowHygiene:
"""Regression guards for CI workflow security and correctness."""

@pytest.fixture
def workflow_files(self) -> list[Path]:
files = list(WORKFLOWS_DIR.glob("*.yml")) + list(WORKFLOWS_DIR.glob("*.yaml"))
if not files:
pytest.skip("No workflow files found")
return files

def test_all_actions_sha_pinned(self, workflow_files: list[Path]) -> None:
"""Every remote action reference must use a 40-char SHA, not a mutable tag.

Mutable tags like @v4 can be silently moved to point at different commits,
creating a supply-chain attack vector. SHA pins lock the exact commit.
"""
violations: list[str] = []
for wf in workflow_files:
for lineno, line in enumerate(wf.read_text(encoding="utf-8").splitlines(), 1):
match = USES_PATTERN.search(line)
if not match:
continue
action, ref = match.group(1), match.group(2)
# Strip inline comments (e.g. "# v4.2.2")
ref = ref.split("#")[0].strip()
if action.startswith(LOCAL_ACTION_PREFIX):
continue
if not SHA_PATTERN.match(ref):
violations.append(f"{wf.name}:{lineno} {action}@{ref}")

assert not violations, (
f"Found {len(violations)} mutable action reference(s). "
"Pin to a 40-char SHA instead:\n" + "\n".join(violations)
)

def test_no_silent_failure_on_validation_steps(self, workflow_files: list[Path]) -> None:
"""Validation/lint/test steps must not suppress failures with '|| true'.

A step whose purpose is to fail the build on defects (linters, type
checkers, security scanners) must not hide failures. This catches the
'validation theater' trap where a real check is neutered.
"""
validation_keywords = ("lint", "check", "test", "audit", "scan", "format", "typecheck")
violations: list[str] = []
for wf in workflow_files:
lines = wf.read_text(encoding="utf-8").splitlines()
for lineno, line in enumerate(lines, 1):
stripped = line.strip()
if "|| true" not in stripped:
continue
# Check if this line or the step name above contains a validation keyword
context = " ".join(lines[max(0, lineno - 5) : lineno]).lower()
if any(kw in context for kw in validation_keywords):
violations.append(f"{wf.name}:{lineno} {stripped[:80]}")

assert not violations, (
f"Found {len(violations)} validation step(s) with '|| true' suppression. "
"Remove the suppression so failures are visible:\n" + "\n".join(violations)
)