Skip to content

Commit df0e264

Browse files
authored
Add actionlint, zizmor, gitleaks, and refurb gates to check.sh (#30)
1 parent e70911a commit df0e264

11 files changed

Lines changed: 421 additions & 21 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,16 @@ jobs:
6161
- name: Install
6262
run: python -m pip install uv
6363

64+
# actionlint and gitleaks are Go binaries (no PyPI wheel), so check.sh self-skips
65+
# them locally like shellcheck. Build them here with the runner's preinstalled Go,
66+
# pinned to a release tag, and put GOPATH/bin on PATH so check.sh enforces them.
67+
# (gitleaks v8's Go module path is still github.com/zricethezav/gitleaks/v8.)
68+
- name: Workflow + secret scanners (actionlint, gitleaks)
69+
run: |
70+
go install github.com/rhysd/actionlint/cmd/actionlint@v1.7.7
71+
go install github.com/zricethezav/gitleaks/v8@v8.21.2
72+
echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH"
73+
6474
- name: Lint, typecheck, test
6575
run: ./scripts/check.sh
6676

‎.gitleaks.toml‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# gitleaks config for the aai CLI secret-scan gate (scripts/check.sh + CI).
2+
#
3+
# Real credentials never live in this repo by design: the API key is stored only in
4+
# the OS keyring (see aai_cli/config.py, KEYRING_SERVICE), never in a dotfile or source
5+
# file. So the only "secrets" gitleaks can legitimately find are the obviously-fake
6+
# placeholder keys used as test fixtures and in planning-doc examples. Allowlist those
7+
# exact values — everything else (real-looking tokens in src/, scripts/, workflows) is
8+
# still scanned and will fail the gate.
9+
[extend]
10+
useDefault = true
11+
12+
[allowlist]
13+
description = "Fake placeholder API keys used only in tests and planning docs"
14+
regexTarget = "match"
15+
regexes = [
16+
'''sk_abcdef1234''',
17+
'''sk_zzzzzz9999''',
18+
]

‎aai_cli/commands/llm.py‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from __future__ import annotations
22

3+
from contextlib import suppress
4+
35
import typer
46
from rich.markup import escape
57

@@ -106,13 +108,11 @@ def ask(transcript_text: str) -> str:
106108

107109
with FollowRenderer(json_mode=json_mode) as render:
108110
transcript: list[str] = []
109-
try:
111+
# Ctrl-C is the normal "stop watching" signal -> exit cleanly (code 0).
112+
with suppress(KeyboardInterrupt):
110113
for turn in stdio.iter_piped_stdin_lines():
111114
transcript.append(turn)
112115
render(ask("\n".join(transcript)), len(transcript))
113-
except KeyboardInterrupt:
114-
# Ctrl-C is the normal "stop watching" signal -> exit cleanly (code 0).
115-
pass
116116

117117
def body(state: AppState, json_mode: bool) -> None:
118118
if not prompt:

‎aai_cli/commands/stream.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ def stream_one(
158158
self, audio: Iterable[bytes], rate: int, *, source_label: str | None = None
159159
) -> None:
160160
merged = config_builder.merge_streaming_params(
161-
flags={**self.base_flags, "sample_rate": rate},
161+
flags=self.base_flags | {"sample_rate": rate},
162162
overrides=self.overrides,
163163
config_file=self.config_file,
164164
)
@@ -453,7 +453,7 @@ def body(state: AppState, json_mode: bool) -> None:
453453
if opts.from_system_audio:
454454
raise UsageError("--show-code does not support macOS system audio capture yet.")
455455
merged = config_builder.merge_streaming_params(
456-
flags={**base_flags, "sample_rate": TARGET_RATE},
456+
flags=base_flags | {"sample_rate": TARGET_RATE},
457457
overrides=config_kv,
458458
config_file=config_file,
459459
)

‎aai_cli/commands/transcribe.py‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -301,10 +301,8 @@ def body(state: AppState, json_mode: bool) -> None:
301301
max_tokens=max_tokens,
302302
)
303303
output.emit(
304-
{
305-
**client.transcript_summary(transcript),
306-
"transform": {"model": model, "steps": steps},
307-
},
304+
client.transcript_summary(transcript)
305+
| {"transform": {"model": model, "steps": steps}},
308306
_render_transform_steps,
309307
json_mode=json_mode,
310308
)

‎aai_cli/help_text.py‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,5 @@ def examples_epilog(examples: Sequence[Example]) -> str:
1919
"""
2020
blocks = ["[bold]Examples[/bold]"]
2121
for description, command in examples:
22-
blocks.append(f"[dim]{escape(description)}[/dim]")
23-
blocks.append(f"$ {escape(command)}")
22+
blocks.extend((f"[dim]{escape(description)}[/dim]", f"$ {escape(command)}"))
2423
return "\n\n".join(blocks)

‎aai_cli/streaming/sources.py‎

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import sys
77
import time
88
import wave
9-
from collections.abc import Callable, Iterator
9+
from collections.abc import Callable, Generator, Iterator
1010
from pathlib import Path
1111
from typing import Any
1212

@@ -65,10 +65,19 @@ def __init__(self, source: str, *, sleep: Callable[[float], object] = time.sleep
6565
def __iter__(self) -> Iterator[bytes]:
6666
chunks = self._wav_chunks() if self._wav else self._ffmpeg_chunks()
6767
produced = 0
68-
for chunk in chunks:
69-
produced += len(chunk)
70-
yield chunk
71-
self._sleep(len(chunk) / (TARGET_RATE * 2)) # ~real-time pacing
68+
# Closing this outer generator early raises GeneratorExit at the `yield` below,
69+
# but a plain `for` loop won't forward it into `chunks`; its cleanup (ffmpeg
70+
# terminate/wait) would then run only at GC, not synchronously. Close it here so
71+
# the subprocess teardown happens deterministically on early stop.
72+
try:
73+
for chunk in chunks:
74+
produced += len(chunk) # pragma: no mutate (only == 0 matters)
75+
yield chunk
76+
# ~real-time pacing; tests inject a no-op sleep, so the duration is
77+
# deliberately not asserted (it's cosmetic, not behavioral).
78+
self._sleep(len(chunk) / (TARGET_RATE * 2)) # pragma: no mutate
79+
finally:
80+
chunks.close()
7281
if produced == 0:
7382
raise CLIError(
7483
f"No audio data in {self.source}.",
@@ -77,7 +86,7 @@ def __iter__(self) -> Iterator[bytes]:
7786
suggestion="Check the file isn't empty or silent.",
7887
)
7988

80-
def _wav_chunks(self) -> Iterator[bytes]:
89+
def _wav_chunks(self) -> Generator[bytes, None, None]:
8190
frames_per_chunk = CHUNK_BYTES // 2
8291
with wave.open(str(self._path), "rb") as w: # _wav implies a local path
8392
while True:
@@ -86,7 +95,7 @@ def _wav_chunks(self) -> Iterator[bytes]:
8695
return
8796
yield data
8897

89-
def _ffmpeg_chunks(self) -> Iterator[bytes]:
98+
def _ffmpeg_chunks(self) -> Generator[bytes, None, None]:
9099
proc = subprocess.Popen(
91100
[
92101
"ffmpeg",

‎pyproject.toml‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,8 @@ dev = [
8080
"vulture>=2.14",
8181
"deptry>=0.23.0",
8282
"import-linter>=2.3",
83+
"zizmor>=1.10",
84+
"coverage>=7.0",
8385
]
8486

8587
[tool.uv]
@@ -232,4 +234,6 @@ audioop-lts = "audioop"
232234
# The CLI templates carry their own requirements and are dependency-checked by
233235
# dedicated install tests, not by the root package metadata.
234236
DEP002 = ["fastapi", "python-dotenv", "python-multipart", "uvicorn"]
235-
DEP004 = ["fastapi", "httpx", "hypothesis", "pytest"]
237+
# coverage is read by scripts/mutation_gate.py (a dev-only gate run from check.sh,
238+
# never shipped in the wheel), so deptry sees a dev dep imported from non-test code.
239+
DEP004 = ["fastapi", "httpx", "hypothesis", "pytest", "coverage"]

‎scripts/check.sh‎

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,34 @@ else
115115
echo " shellcheck not found; skipping (CI runs it)"
116116
fi
117117

118+
echo "==> actionlint (GitHub Actions workflow lint)"
119+
# Static-lint the CI workflows the same way shellcheck covers install.sh: catches
120+
# bad expressions, undefined needs/matrix refs, and shell bugs inside `run:` blocks.
121+
# Go binary (no PyPI wheel), so it self-skips locally and CI installs it (see ci.yml).
122+
if command -v actionlint >/dev/null 2>&1; then
123+
actionlint
124+
else
125+
echo " actionlint not found; skipping (CI runs it)"
126+
fi
127+
128+
echo "==> zizmor (GitHub Actions security audit)"
129+
# Audits the workflows for CI security issues (script injection via untrusted
130+
# ${{ github.* }} interpolation, over-broad token permissions, unpinned actions).
131+
# Pip-installable, so it runs in the locked env as a hard gate like ruff/mypy.
132+
# --offline keeps it deterministic (skips audits that would query the GitHub API).
133+
uv run zizmor --offline .github/workflows
134+
135+
echo "==> gitleaks (secret scan)"
136+
# Defends the project's core promise that credentials never land in the repo (the API
137+
# key lives only in the OS keyring). Scans the working tree; obviously-fake test/doc
138+
# fixtures are allowlisted in .gitleaks.toml. Go binary, so it self-skips locally and
139+
# CI installs it (see ci.yml).
140+
if command -v gitleaks >/dev/null 2>&1; then
141+
gitleaks dir --no-banner --redact -c .gitleaks.toml .
142+
else
143+
echo " gitleaks not found; skipping (CI runs it)"
144+
fi
145+
118146
echo "==> generated --show-code compile gate"
119147
generated_code_dir="$(mktemp -d)"
120148
trap cleanup_generated_code_dir EXIT
@@ -134,7 +162,7 @@ echo "==> pytest (with branch-coverage gate)"
134162
# uv run pytest -m e2e
135163
# uv run pytest -m install
136164
# uv run pytest -m install_script
137-
uv run pytest -q --strict-config --strict-markers -m "not e2e and not install and not install_script" --cov=aai_cli --cov-branch --cov-report=term-missing --cov-report=xml --cov-fail-under=90
165+
uv run pytest -q --strict-config --strict-markers -m "not e2e and not install and not install_script" --cov=aai_cli --cov-branch --cov-context=test --cov-report=term-missing --cov-report=xml --cov-fail-under=90
138166

139167
echo "==> diff-cover (patch coverage: every changed line must be tested)"
140168
# The 90% gate above is project-wide, so new code can ride on the existing suite and
@@ -148,6 +176,18 @@ else
148176
echo " origin/main not found; skipping patch-coverage gate (CI provides it)"
149177
fi
150178

179+
echo "==> mutation gate (diff-scoped: a changed line's test must fail when it breaks)"
180+
# Coverage proves a changed line ran; this proves a test would FAIL if it broke.
181+
# Mutates only the lines changed vs origin/main and reruns just the tests that cover
182+
# each mutant (per-test contexts from the .coverage written above). Survivors mean a
183+
# weak/missing assertion — fix it or mark the line `# pragma: no mutate`. Self-skips
184+
# when origin/main is absent (same as diff-cover).
185+
if git rev-parse --verify --quiet origin/main >/dev/null; then
186+
uv run python scripts/mutation_gate.py origin/main
187+
else
188+
echo " origin/main not found; skipping mutation gate (CI provides it)"
189+
fi
190+
151191
echo "==> no new static-analysis escape hatches"
152192
# Existing escape hatches are tolerated for now; new ones must be refactored away or
153193
# justified by changing this gate deliberately. Broad noqa/type-ignore/no-cover are
@@ -162,6 +202,20 @@ if git rev-parse --verify --quiet origin/main >/dev/null; then
162202
exit 1
163203
fi
164204

205+
# Test-suite escape hatches, same net-new-only policy: a skip/xfail is how an agent
206+
# makes a red test go away instead of fixing it, and time.sleep() is the classic
207+
# source of flakiness (use events/polling). The legitimate existing skips guard the
208+
# env-gated marker suites (e2e/install/install_script) and live on origin/main, so
209+
# they aren't added diff lines and don't trip this; a genuinely-needed new one must
210+
# update this gate deliberately. Scoped to tests/ — production sleeps are fine.
211+
test_shortcuts="$(git diff -U0 origin/main -- tests \
212+
| rg '^\+.*(pytest\.skip\(|pytest\.xfail\(|@pytest\.mark\.(skip|xfail)|\btime\.sleep\()' || true)"
213+
if [[ -n "$test_shortcuts" ]]; then
214+
printf '%s\n' "$test_shortcuts"
215+
echo "New test skip/xfail/time.sleep found; fix the test (or sync properly) or update the gate explicitly."
216+
exit 1
217+
fi
218+
165219
base_any_count="$({ git grep -n "Any" origin/main -- aai_cli tests || true; } | wc -l | tr -d '[:space:]')"
166220
work_any_count="$({ rg -n "Any" aai_cli tests || true; } | wc -l | tr -d '[:space:]')"
167221
if (( work_any_count > base_any_count )); then

0 commit comments

Comments
 (0)