mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-09-17 00:26:26 +00:00
ci: add GitHub Actions workflow - LaTeX smoke compiles, skill lint, CLI typechecks, placeholder integrity (#59)
Every PR to this repo is currently verified by hand. This adds the checks
a machine can do:
- latex-smoke: compiles cv/main_example.tex (lualatex) and the new
cover_letters/cover_example.tex (xelatex) in the texlive/texlive
container, failing on any LaTeX error. Exact page-count assertions
(CV=2, cover letter=1) run on the upstream repo only
- lint (tools/lint_skills.py, also runnable locally): every SKILL.md has
parseable YAML frontmatter with name+description (frontmatter breakage
happened before - 37a0eed), allowed-tools 'bun run <path>' targets
exist, command files start with a '# /<name>' title, settings.json is
valid JSON with a permissions.allow list
- cli-typecheck: bun install + tsc --noEmit for all five portal CLIs
(matrix, fail-fast off)
- placeholder-integrity (upstream only): tracked template files still
carry their placeholder tokens, catching accidental personal-data
commits before they land
Fork-friendly by design: /setup personalizes CLAUDE.md, the skill files,
and main_example.tex in forks, so placeholder checks and exact page
counts are guarded with github.repository == upstream; compile success
and lint run everywhere. Live CLI smoke tests are deliberately excluded:
network-flaky, and linkedin-search is personal-use-only per its own ToS
warning - CI-automated requests would violate it. CLIs are typechecked
instead.
The cover letter previously had no tracked example (cover_*.tex is
gitignored), so cover_example.tex is new: a placeholder letter following
the documented 06 structure, demonstrating the correct itemize-outside-
lettercontent pattern. It doubles as the structural reference /apply
Step 2 looks for on fresh clones, which until now matched nothing. The
gitignore exception is ordered after Cover_*.tex because case-insensitive
filesystems match that pattern against cover_example.tex too.
Writing it surfaced a latent bug in the documented template itself:
06-cover-letter-templates.md's structure ends with \closing{Kind
regards,\} - but cover.cls appends its own \, and the doubled break
produces '! LaTeX Error: There's no line here to end.' on every compile
(nonstopmode swallows it, so it went unnoticed). Fixed in 06 and noted
in the example.
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Lint the repo's skill, command, and settings files.
|
||||
|
||||
Run from anywhere: python tools/lint_skills.py
|
||||
|
||||
Checks:
|
||||
- Every SKILL.md (.claude/skills/*, .agents/skills/*) has YAML frontmatter that
|
||||
parses, with non-empty `name` and `description` keys
|
||||
- `allowed-tools` entries of the form `Bash(bun run <path> *)` point at files
|
||||
that exist (skill paths resolve relative to the repo root and to .agents/)
|
||||
- Every .claude/commands/*.md starts with a `# /<name>` title
|
||||
- .claude/settings.json is valid JSON with a permissions.allow list
|
||||
|
||||
Exit code 0 on success, 1 with a failure list otherwise.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
sys.exit("lint_skills.py requires PyYAML: pip install pyyaml")
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
errors: list[str] = []
|
||||
|
||||
|
||||
def rel(path: Path) -> str:
|
||||
return str(path.relative_to(ROOT))
|
||||
|
||||
|
||||
def check_skill(path: Path) -> None:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
if not text.startswith("---\n"):
|
||||
errors.append(f"{rel(path)}: missing YAML frontmatter (file must start with ---)")
|
||||
return
|
||||
end = text.find("\n---", 4)
|
||||
if end == -1:
|
||||
errors.append(f"{rel(path)}: unterminated YAML frontmatter")
|
||||
return
|
||||
try:
|
||||
data = yaml.safe_load(text[4:end])
|
||||
except yaml.YAMLError as exc:
|
||||
errors.append(f"{rel(path)}: frontmatter is not valid YAML: {exc}")
|
||||
return
|
||||
if not isinstance(data, dict):
|
||||
errors.append(f"{rel(path)}: frontmatter did not parse to a mapping")
|
||||
return
|
||||
for key in ("name", "description"):
|
||||
if not data.get(key):
|
||||
errors.append(f"{rel(path)}: frontmatter missing required key '{key}'")
|
||||
|
||||
allowed = data.get("allowed-tools", "")
|
||||
if isinstance(allowed, str):
|
||||
for match in re.finditer(r"bun run ([^\s*)]+)", allowed):
|
||||
target = match.group(1)
|
||||
candidates = [ROOT / target, ROOT / ".agents" / target]
|
||||
if not any(c.is_file() for c in candidates):
|
||||
errors.append(f"{rel(path)}: allowed-tools references a missing file: {target}")
|
||||
|
||||
|
||||
def check_command(path: Path) -> None:
|
||||
lines = path.read_text(encoding="utf-8").lstrip().splitlines()
|
||||
first = lines[0] if lines else ""
|
||||
if not first.startswith("# /"):
|
||||
errors.append(f"{rel(path)}: command file must start with a '# /<name>' title (found: {first[:50]!r})")
|
||||
|
||||
|
||||
def check_settings() -> None:
|
||||
path = ROOT / ".claude" / "settings.json"
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
errors.append(f".claude/settings.json: {exc}")
|
||||
return
|
||||
if not isinstance(data.get("permissions", {}).get("allow"), list):
|
||||
errors.append(".claude/settings.json: expected permissions.allow to be a list")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
skills = sorted(ROOT.glob(".claude/skills/*/SKILL.md")) + sorted(ROOT.glob(".agents/skills/*/SKILL.md"))
|
||||
commands = sorted((ROOT / ".claude" / "commands").glob("*.md"))
|
||||
if not skills:
|
||||
errors.append("no SKILL.md files found - glob roots are wrong or the tree moved")
|
||||
if not commands:
|
||||
errors.append("no command files found under .claude/commands/")
|
||||
|
||||
for skill in skills:
|
||||
check_skill(skill)
|
||||
for command in commands:
|
||||
check_command(command)
|
||||
check_settings()
|
||||
|
||||
if errors:
|
||||
print(f"lint_skills: {len(errors)} failure(s)")
|
||||
for err in errors:
|
||||
print(f" - {err}")
|
||||
return 1
|
||||
print(f"lint_skills: OK ({len(skills)} skills, {len(commands)} commands, settings.json)")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user