Skip to content

Commit ebd3097

Browse files
authored
ci: add dependency audit workflow (#3138)
* ci: add dependency audit workflow Add a Security Audit workflow with a dependency-audit job. Push/PR/manual runs pip-audit against a committed --generate-hashes requirements snapshot (.github/security-audit-requirements.txt) for deterministic CI, while the weekly scheduled run resolves the runtime + test dependency set live across the supported Python/OS matrix to surface newly published advisories. A sync gate (.github/scripts/check_security_requirements.py) fails PRs whose dependency inputs changed without refreshing the committed snapshot, so the committed file can't silently drift from pyproject.toml. Assisted-by: Codex (model: GPT-5, autonomous) * ci: split dependency audit schedule matrix Assisted-by: Codex (model: GPT-5, autonomous) * ci: harden dependency audit sync checks Assisted-by: Codex (model: GPT-5, autonomous) * ci: align security workflow python pin Assisted-by: Codex (model: GPT-5, autonomous) * ci: refresh dependency audit baseline Assisted-by: Codex (model: GPT-5, autonomous) * docs: clarify security snapshot audit Assisted-by: Codex (model: GPT-5, autonomous)
1 parent 115bc94 commit ebd3097

5 files changed

Lines changed: 816 additions & 0 deletions

File tree

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
"""Check that committed security audit requirements are up to date."""
2+
3+
from __future__ import annotations
4+
5+
import os
6+
import subprocess
7+
import sys
8+
from pathlib import Path
9+
10+
11+
REPO_ROOT = Path(__file__).resolve().parents[2]
12+
COMMITTED_REQUIREMENTS = REPO_ROOT / ".github" / "security-audit-requirements.txt"
13+
DEPENDENCY_INPUTS = ("pyproject.toml", ".github/security-audit-requirements.txt")
14+
15+
16+
def _dependency_diff_refs() -> tuple[str, str]:
17+
base_ref = os.environ.get("DEPENDENCY_DIFF_BASE", "").strip()
18+
head_ref = os.environ.get("DEPENDENCY_DIFF_HEAD", "").strip() or "HEAD"
19+
if base_ref and not set(base_ref) <= {"0"}:
20+
return base_ref, head_ref
21+
# Fallback when no usable base is supplied (push with an all-zero
22+
# ``github.event.before``, manual dispatch, etc.). ``HEAD^`` fails on a
23+
# shallow checkout or a single-commit repo; that ``git diff`` error is
24+
# caught by the caller and deliberately treated as "inputs changed" so the
25+
# audit runs anyway — failing safe (audit) rather than skipping silently.
26+
return "HEAD^", "HEAD"
27+
28+
29+
def _dependency_inputs_changed() -> bool:
30+
base_ref, head_ref = _dependency_diff_refs()
31+
try:
32+
result = subprocess.run(
33+
[
34+
"git",
35+
"diff",
36+
"--name-only",
37+
base_ref,
38+
head_ref,
39+
"--",
40+
*DEPENDENCY_INPUTS,
41+
],
42+
check=True,
43+
cwd=REPO_ROOT,
44+
stderr=subprocess.PIPE,
45+
stdout=subprocess.PIPE,
46+
text=True,
47+
)
48+
except subprocess.CalledProcessError as exc:
49+
print(
50+
"Could not determine changed dependency inputs; checking requirements.",
51+
file=sys.stderr,
52+
)
53+
if exc.stderr:
54+
print(exc.stderr.strip(), file=sys.stderr)
55+
return True
56+
57+
changed_inputs = [line for line in result.stdout.splitlines() if line]
58+
if not changed_inputs:
59+
print("Dependency audit inputs unchanged; sync check skipped.")
60+
return False
61+
62+
print(f"Dependency audit inputs changed: {', '.join(changed_inputs)}")
63+
return True
64+
65+
66+
def main() -> int:
67+
if not _dependency_inputs_changed():
68+
return 0
69+
70+
generated_requirements_env = os.environ.get("GENERATED_REQUIREMENTS", "").strip()
71+
if not generated_requirements_env:
72+
print(
73+
"GENERATED_REQUIREMENTS must be set to the temporary output file path.",
74+
file=sys.stderr,
75+
)
76+
return 1
77+
78+
generated_requirements = Path(generated_requirements_env)
79+
generated_requirements.parent.mkdir(parents=True, exist_ok=True)
80+
81+
subprocess.run(
82+
[
83+
"uv",
84+
"pip",
85+
"compile",
86+
"pyproject.toml",
87+
"--extra",
88+
"test",
89+
"--universal",
90+
"--upgrade",
91+
"--generate-hashes",
92+
"--quiet",
93+
"--no-header",
94+
"--output-file",
95+
str(generated_requirements),
96+
],
97+
check=True,
98+
cwd=REPO_ROOT,
99+
)
100+
101+
committed = COMMITTED_REQUIREMENTS.read_text(encoding="utf-8")
102+
generated = generated_requirements.read_text(encoding="utf-8")
103+
if committed == generated:
104+
return 0
105+
106+
print(
107+
"Regenerate .github/security-audit-requirements.txt with the documented "
108+
"uv pip compile command.",
109+
file=sys.stderr,
110+
)
111+
return 1
112+
113+
114+
if __name__ == "__main__":
115+
raise SystemExit(main())

0 commit comments

Comments
 (0)